Spaces:
No application file
No application file
Upload 59 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +12 -0
- .env.example +74 -0
- 001_initial_schema.sql +827 -0
- 002_seed_admin.sql +173 -0
- 01_schema.sh +34 -0
- ActionEditor.tsx +208 -0
- ActionExecutor.cs +177 -0
- AiAnalystPanel.tsx +244 -0
- AiController.cs +231 -0
- AppShell.tsx +90 -0
- AuthController.cs +234 -0
- BREController.cs +429 -0
- BREDbContext.cs +337 -0
- CachedRuleLoader.cs +131 -0
- ConditionEvaluator.cs +167 -0
- ConditionGroupEditor.tsx +166 -0
- ConditionNodeEditor.tsx +197 -0
- DashboardPage.tsx +269 -0
- DeviationManagementPage.tsx +287 -0
- Dockerfile.backend +84 -0
- Dockerfile.frontend +63 -0
- Entities.cs +454 -0
- IRuleExecutionService.cs +217 -0
- OptimAI.BRE.AuditService.csproj +16 -0
- OptimAI.BRE.ClientMgmt.csproj +16 -0
- OptimAI.BRE.Gateway.csproj +58 -0
- OptimAI.BRE.IdentityService.csproj +20 -0
- OptimAI.BRE.ReportService.csproj +16 -0
- OptimAI.BRE.RuleDesigner.csproj +16 -0
- OptimAI.BRE.RuleEngine.csproj +21 -0
- OptimAI.BRE.Shared.csproj +12 -0
- OptimAI.BRE.sln +35 -0
- Program.cs +275 -0
- RepositoryStubs.cs +480 -0
- RiskScoringEngine.cs +285 -0
- RuleBuilder.tsx +274 -0
- RuleDesignerController.cs +458 -0
- RuleExecutionService.cs +264 -0
- RulePreview.tsx +176 -0
- SandboxPage.tsx +256 -0
- TenantMiddleware.cs +148 -0
- api-client.ts +61 -0
- appsettings.Development.json +40 -0
- appsettings.Production.json +39 -0
- appsettings.json +39 -0
- deployment.yaml +481 -0
- docker-compose.yml +335 -0
- globals.css +68 -0
- index.ts +269 -0
- layout.tsx +25 -0
.dockerignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
.next
|
| 4 |
+
node_modules
|
| 5 |
+
npm-debug.log*
|
| 6 |
+
.env
|
| 7 |
+
.env.*
|
| 8 |
+
!.env.example
|
| 9 |
+
coverage
|
| 10 |
+
.nyc_output
|
| 11 |
+
.DS_Store
|
| 12 |
+
*.log
|
.env.example
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================================
|
| 2 |
+
# OPTIM AI BRE Engine — Environment Variables
|
| 3 |
+
# STEP 1: Copy this file: cp .env.example .env
|
| 4 |
+
# STEP 2: Edit .env and change every CHANGE_ME value
|
| 5 |
+
# NEVER commit .env to version control
|
| 6 |
+
# ==============================================================
|
| 7 |
+
|
| 8 |
+
# ==============================================================
|
| 9 |
+
# POSTGRESQL
|
| 10 |
+
# ==============================================================
|
| 11 |
+
POSTGRES_DB=optimai_bre
|
| 12 |
+
POSTGRES_USER=optimai
|
| 13 |
+
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD_HERE
|
| 14 |
+
|
| 15 |
+
# ==============================================================
|
| 16 |
+
# REDIS
|
| 17 |
+
# ==============================================================
|
| 18 |
+
REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD_HERE
|
| 19 |
+
|
| 20 |
+
# ==============================================================
|
| 21 |
+
# JWT SECRET KEY
|
| 22 |
+
# Must be minimum 32 characters, random string.
|
| 23 |
+
# Generate one:
|
| 24 |
+
# Windows: -join ((65..90)+(97..122)+(48..57) | Get-Random -Count 64 | % {[char]$_})
|
| 25 |
+
# Linux: openssl rand -base64 48
|
| 26 |
+
# ==============================================================
|
| 27 |
+
JWT_SECRET_KEY=CHANGE_ME_MINIMUM_32_CHAR_RANDOM_STRING_FOR_PRODUCTION
|
| 28 |
+
|
| 29 |
+
JWT_ISSUER=OptimAI.BRE
|
| 30 |
+
JWT_AUDIENCE=OptimAI.BRE.Clients
|
| 31 |
+
JWT_ACCESS_TOKEN_EXPIRY_MINUTES=480
|
| 32 |
+
JWT_REFRESH_TOKEN_EXPIRY_DAYS=30
|
| 33 |
+
|
| 34 |
+
# ==============================================================
|
| 35 |
+
# AI SERVICE (Optional — BRE works without this)
|
| 36 |
+
# Leave AZURE_OPENAI_KEY blank to disable AI features.
|
| 37 |
+
#
|
| 38 |
+
# Option A — Azure OpenAI:
|
| 39 |
+
# USE_AZURE_OPENAI=true
|
| 40 |
+
# AZURE_OPENAI_ENDPOINT=https://YOUR-RESOURCE.openai.azure.com/
|
| 41 |
+
# AZURE_OPENAI_KEY=your-azure-openai-key
|
| 42 |
+
#
|
| 43 |
+
# Option B — OpenAI:
|
| 44 |
+
# USE_AZURE_OPENAI=false
|
| 45 |
+
# AZURE_OPENAI_KEY=sk-your-openai-key-here
|
| 46 |
+
# ==============================================================
|
| 47 |
+
USE_AZURE_OPENAI=false
|
| 48 |
+
AZURE_OPENAI_ENDPOINT=
|
| 49 |
+
AZURE_OPENAI_KEY=
|
| 50 |
+
AZURE_OPENAI_MODEL=gpt-4o
|
| 51 |
+
|
| 52 |
+
# ==============================================================
|
| 53 |
+
# APPLICATION DOMAIN
|
| 54 |
+
# Used for CORS allowed origins.
|
| 55 |
+
# Set to your server IP or domain. Examples:
|
| 56 |
+
# http://192.168.1.100
|
| 57 |
+
# https://bre.yourcompany.com
|
| 58 |
+
# ==============================================================
|
| 59 |
+
APP_DOMAIN=http://localhost
|
| 60 |
+
|
| 61 |
+
# ==============================================================
|
| 62 |
+
# RULE ENGINE SETTINGS
|
| 63 |
+
# ==============================================================
|
| 64 |
+
RULE_ENGINE_MAX_CONCURRENT=100
|
| 65 |
+
RULE_ENGINE_TIMEOUT_MS=5000
|
| 66 |
+
RULE_ENGINE_CACHE_TTL_MINUTES=5
|
| 67 |
+
LOG_LEVEL=Information
|
| 68 |
+
|
| 69 |
+
# ==============================================================
|
| 70 |
+
# POSTGRES VERSIONS (pin for reproducibility)
|
| 71 |
+
# ==============================================================
|
| 72 |
+
POSTGRES_VERSION=15-alpine
|
| 73 |
+
REDIS_VERSION=7-alpine
|
| 74 |
+
NGINX_VERSION=1.25-alpine
|
001_initial_schema.sql
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ============================================================
|
| 2 |
+
-- OPTIM AI BRE ENGINE - COMPLETE DATABASE SCHEMA
|
| 3 |
+
-- PostgreSQL 15+
|
| 4 |
+
-- Multi-Tenant, Production-Grade
|
| 5 |
+
-- ============================================================
|
| 6 |
+
|
| 7 |
+
-- Enable extensions
|
| 8 |
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
| 9 |
+
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
| 10 |
+
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
| 11 |
+
|
| 12 |
+
-- ============================================================
|
| 13 |
+
-- SCHEMA: TENANTS & MULTI-TENANCY
|
| 14 |
+
-- ============================================================
|
| 15 |
+
|
| 16 |
+
CREATE TABLE tenants (
|
| 17 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 18 |
+
tenant_code VARCHAR(50) UNIQUE NOT NULL,
|
| 19 |
+
tenant_name VARCHAR(200) NOT NULL,
|
| 20 |
+
display_name VARCHAR(200),
|
| 21 |
+
logo_url TEXT,
|
| 22 |
+
primary_color VARCHAR(10) DEFAULT '#1E40AF',
|
| 23 |
+
secondary_color VARCHAR(10) DEFAULT '#3B82F6',
|
| 24 |
+
plan_type VARCHAR(50) NOT NULL DEFAULT 'ENTERPRISE', -- STARTER, PROFESSIONAL, ENTERPRISE
|
| 25 |
+
max_rules INTEGER NOT NULL DEFAULT 1000,
|
| 26 |
+
max_executions_per_day BIGINT NOT NULL DEFAULT 1000000,
|
| 27 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 28 |
+
trial_end_date TIMESTAMPTZ,
|
| 29 |
+
subscription_end_date TIMESTAMPTZ,
|
| 30 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 31 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 32 |
+
created_by UUID,
|
| 33 |
+
settings JSONB NOT NULL DEFAULT '{}'::JSONB
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
CREATE TABLE tenant_configurations (
|
| 37 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 38 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 39 |
+
config_key VARCHAR(200) NOT NULL,
|
| 40 |
+
config_value TEXT,
|
| 41 |
+
config_type VARCHAR(50) NOT NULL DEFAULT 'STRING', -- STRING, JSON, BOOLEAN, NUMBER
|
| 42 |
+
category VARCHAR(100),
|
| 43 |
+
is_encrypted BOOLEAN DEFAULT FALSE,
|
| 44 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 45 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 46 |
+
UNIQUE(tenant_id, config_key)
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
-- ============================================================
|
| 50 |
+
-- SCHEMA: IDENTITY & ACCESS MANAGEMENT
|
| 51 |
+
-- ============================================================
|
| 52 |
+
|
| 53 |
+
CREATE TABLE users (
|
| 54 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 55 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 56 |
+
email VARCHAR(320) NOT NULL,
|
| 57 |
+
username VARCHAR(100) NOT NULL,
|
| 58 |
+
password_hash TEXT NOT NULL,
|
| 59 |
+
full_name VARCHAR(200) NOT NULL,
|
| 60 |
+
employee_id VARCHAR(100),
|
| 61 |
+
designation VARCHAR(200),
|
| 62 |
+
department VARCHAR(200),
|
| 63 |
+
mobile VARCHAR(20),
|
| 64 |
+
profile_image_url TEXT,
|
| 65 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 66 |
+
is_email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
| 67 |
+
is_mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
| 68 |
+
mfa_secret TEXT,
|
| 69 |
+
last_login_at TIMESTAMPTZ,
|
| 70 |
+
last_login_ip INET,
|
| 71 |
+
failed_login_count INTEGER NOT NULL DEFAULT 0,
|
| 72 |
+
locked_until TIMESTAMPTZ,
|
| 73 |
+
password_changed_at TIMESTAMPTZ,
|
| 74 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 75 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 76 |
+
UNIQUE(tenant_id, email),
|
| 77 |
+
UNIQUE(tenant_id, username)
|
| 78 |
+
);
|
| 79 |
+
|
| 80 |
+
CREATE TABLE roles (
|
| 81 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 82 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 83 |
+
role_code VARCHAR(100) NOT NULL,
|
| 84 |
+
role_name VARCHAR(200) NOT NULL,
|
| 85 |
+
description TEXT,
|
| 86 |
+
is_system_role BOOLEAN NOT NULL DEFAULT FALSE,
|
| 87 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 88 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 89 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 90 |
+
UNIQUE(tenant_id, role_code)
|
| 91 |
+
);
|
| 92 |
+
|
| 93 |
+
CREATE TABLE permissions (
|
| 94 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 95 |
+
permission_code VARCHAR(200) UNIQUE NOT NULL,
|
| 96 |
+
permission_name VARCHAR(200) NOT NULL,
|
| 97 |
+
module VARCHAR(100) NOT NULL,
|
| 98 |
+
description TEXT,
|
| 99 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 100 |
+
);
|
| 101 |
+
|
| 102 |
+
CREATE TABLE role_permissions (
|
| 103 |
+
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
| 104 |
+
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
| 105 |
+
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 106 |
+
granted_by UUID,
|
| 107 |
+
PRIMARY KEY (role_id, permission_id)
|
| 108 |
+
);
|
| 109 |
+
|
| 110 |
+
CREATE TABLE user_roles (
|
| 111 |
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
| 112 |
+
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
| 113 |
+
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 114 |
+
assigned_by UUID,
|
| 115 |
+
PRIMARY KEY (user_id, role_id)
|
| 116 |
+
);
|
| 117 |
+
|
| 118 |
+
CREATE TABLE api_keys (
|
| 119 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 120 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 121 |
+
key_name VARCHAR(200) NOT NULL,
|
| 122 |
+
api_key_hash TEXT NOT NULL UNIQUE,
|
| 123 |
+
api_key_prefix VARCHAR(20) NOT NULL,
|
| 124 |
+
scopes TEXT[] NOT NULL DEFAULT '{}',
|
| 125 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 126 |
+
expires_at TIMESTAMPTZ,
|
| 127 |
+
last_used_at TIMESTAMPTZ,
|
| 128 |
+
rate_limit_per_minute INTEGER NOT NULL DEFAULT 1000,
|
| 129 |
+
created_by UUID NOT NULL REFERENCES users(id),
|
| 130 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 131 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 132 |
+
);
|
| 133 |
+
|
| 134 |
+
CREATE TABLE refresh_tokens (
|
| 135 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 136 |
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
| 137 |
+
token_hash TEXT NOT NULL UNIQUE,
|
| 138 |
+
expires_at TIMESTAMPTZ NOT NULL,
|
| 139 |
+
is_revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
| 140 |
+
ip_address INET,
|
| 141 |
+
user_agent TEXT,
|
| 142 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 143 |
+
);
|
| 144 |
+
|
| 145 |
+
-- ============================================================
|
| 146 |
+
-- SCHEMA: PRODUCT & BRANCH CONFIGURATION
|
| 147 |
+
-- ============================================================
|
| 148 |
+
|
| 149 |
+
CREATE TABLE products (
|
| 150 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 151 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 152 |
+
product_code VARCHAR(100) NOT NULL,
|
| 153 |
+
product_name VARCHAR(200) NOT NULL,
|
| 154 |
+
product_type VARCHAR(100) NOT NULL, -- VEHICLE_LOAN, TRACTOR_LOAN, AUTO_LOAN, CV_LOAN, MSME, HOME_LOAN, PERSONAL_LOAN
|
| 155 |
+
description TEXT,
|
| 156 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 157 |
+
config JSONB NOT NULL DEFAULT '{}'::JSONB,
|
| 158 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 159 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 160 |
+
UNIQUE(tenant_id, product_code)
|
| 161 |
+
);
|
| 162 |
+
|
| 163 |
+
CREATE TABLE branches (
|
| 164 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 165 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 166 |
+
branch_code VARCHAR(100) NOT NULL,
|
| 167 |
+
branch_name VARCHAR(200) NOT NULL,
|
| 168 |
+
region VARCHAR(100),
|
| 169 |
+
state VARCHAR(100),
|
| 170 |
+
city VARCHAR(100),
|
| 171 |
+
zone VARCHAR(100),
|
| 172 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 173 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 174 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 175 |
+
UNIQUE(tenant_id, branch_code)
|
| 176 |
+
);
|
| 177 |
+
|
| 178 |
+
CREATE TABLE loan_stages (
|
| 179 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 180 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 181 |
+
stage_code VARCHAR(100) NOT NULL,
|
| 182 |
+
stage_name VARCHAR(200) NOT NULL,
|
| 183 |
+
stage_order INTEGER NOT NULL DEFAULT 0,
|
| 184 |
+
description TEXT,
|
| 185 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 186 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 187 |
+
UNIQUE(tenant_id, stage_code)
|
| 188 |
+
);
|
| 189 |
+
|
| 190 |
+
-- ============================================================
|
| 191 |
+
-- SCHEMA: RULE ENGINE CORE
|
| 192 |
+
-- ============================================================
|
| 193 |
+
|
| 194 |
+
CREATE TABLE rule_categories (
|
| 195 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 196 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 197 |
+
category_code VARCHAR(100) NOT NULL,
|
| 198 |
+
category_name VARCHAR(200) NOT NULL,
|
| 199 |
+
description TEXT,
|
| 200 |
+
icon VARCHAR(100),
|
| 201 |
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
| 202 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 203 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 204 |
+
UNIQUE(tenant_id, category_code)
|
| 205 |
+
);
|
| 206 |
+
|
| 207 |
+
CREATE TABLE rules (
|
| 208 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 209 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 210 |
+
rule_code VARCHAR(200) NOT NULL,
|
| 211 |
+
rule_name VARCHAR(500) NOT NULL,
|
| 212 |
+
description TEXT,
|
| 213 |
+
category_id UUID REFERENCES rule_categories(id),
|
| 214 |
+
rule_type VARCHAR(100) NOT NULL, -- ELIGIBILITY, CREDIT, BUREAU, FI, VALUATION, FRAUD, COMPLIANCE, DEVIATION, INCOME, KYC
|
| 215 |
+
priority INTEGER NOT NULL DEFAULT 100,
|
| 216 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 217 |
+
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
| 218 |
+
is_draft BOOLEAN NOT NULL DEFAULT TRUE,
|
| 219 |
+
status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', -- DRAFT, PENDING_APPROVAL, APPROVED, PUBLISHED, ARCHIVED
|
| 220 |
+
current_version_id UUID,
|
| 221 |
+
tags TEXT[] DEFAULT '{}',
|
| 222 |
+
created_by UUID NOT NULL REFERENCES users(id),
|
| 223 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 224 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 225 |
+
UNIQUE(tenant_id, rule_code)
|
| 226 |
+
);
|
| 227 |
+
|
| 228 |
+
CREATE TABLE rule_versions (
|
| 229 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 230 |
+
rule_id UUID NOT NULL REFERENCES rules(id) ON DELETE CASCADE,
|
| 231 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 232 |
+
version_number INTEGER NOT NULL,
|
| 233 |
+
version_label VARCHAR(50), -- e.g., v1.0, v1.1
|
| 234 |
+
rule_definition JSONB NOT NULL, -- Complete rule AST
|
| 235 |
+
change_summary TEXT,
|
| 236 |
+
is_current BOOLEAN NOT NULL DEFAULT FALSE,
|
| 237 |
+
status VARCHAR(50) NOT NULL DEFAULT 'DRAFT',
|
| 238 |
+
approved_by UUID REFERENCES users(id),
|
| 239 |
+
approved_at TIMESTAMPTZ,
|
| 240 |
+
published_by UUID REFERENCES users(id),
|
| 241 |
+
published_at TIMESTAMPTZ,
|
| 242 |
+
created_by UUID NOT NULL REFERENCES users(id),
|
| 243 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 244 |
+
UNIQUE(rule_id, version_number)
|
| 245 |
+
);
|
| 246 |
+
|
| 247 |
+
-- Rule Definition JSON Structure (stored in rule_versions.rule_definition):
|
| 248 |
+
-- {
|
| 249 |
+
-- "conditions": {
|
| 250 |
+
-- "operator": "AND|OR",
|
| 251 |
+
-- "rules": [
|
| 252 |
+
-- {
|
| 253 |
+
-- "id": "uuid",
|
| 254 |
+
-- "field": "bureau_score",
|
| 255 |
+
-- "operator": "LESS_THAN",
|
| 256 |
+
-- "value": 650,
|
| 257 |
+
-- "valueType": "NUMBER"
|
| 258 |
+
-- },
|
| 259 |
+
-- {
|
| 260 |
+
-- "operator": "OR",
|
| 261 |
+
-- "rules": [...] -- nested group
|
| 262 |
+
-- }
|
| 263 |
+
-- ]
|
| 264 |
+
-- },
|
| 265 |
+
-- "actions": [
|
| 266 |
+
-- { "type": "SET_DECISION", "value": "REJECT" },
|
| 267 |
+
-- { "type": "SET_RISK", "value": "HIGH" },
|
| 268 |
+
-- { "type": "ADD_DEVIATION", "value": "LOW_BUREAU_SCORE" },
|
| 269 |
+
-- { "type": "SET_FIELD", "field": "recommendation", "value": "Reject" }
|
| 270 |
+
-- ],
|
| 271 |
+
-- "metadata": {
|
| 272 |
+
-- "executionOrder": 1,
|
| 273 |
+
-- "stopOnMatch": true,
|
| 274 |
+
-- "errorHandling": "SKIP"
|
| 275 |
+
-- }
|
| 276 |
+
-- }
|
| 277 |
+
|
| 278 |
+
CREATE TABLE rule_scopes (
|
| 279 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 280 |
+
rule_id UUID NOT NULL REFERENCES rules(id) ON DELETE CASCADE,
|
| 281 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 282 |
+
scope_type VARCHAR(50) NOT NULL, -- PRODUCT, BRANCH, STAGE, USER_ROLE, GLOBAL
|
| 283 |
+
scope_value VARCHAR(200) NOT NULL, -- product_code, branch_code, stage_code, role_code, or '*' for all
|
| 284 |
+
is_excluded BOOLEAN NOT NULL DEFAULT FALSE,
|
| 285 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 286 |
+
);
|
| 287 |
+
|
| 288 |
+
CREATE TABLE rule_sets (
|
| 289 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 290 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 291 |
+
set_code VARCHAR(200) NOT NULL,
|
| 292 |
+
set_name VARCHAR(500) NOT NULL,
|
| 293 |
+
description TEXT,
|
| 294 |
+
execution_mode VARCHAR(50) NOT NULL DEFAULT 'ALL', -- ALL, FIRST_MATCH, SCORED
|
| 295 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 296 |
+
created_by UUID NOT NULL REFERENCES users(id),
|
| 297 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 298 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 299 |
+
UNIQUE(tenant_id, set_code)
|
| 300 |
+
);
|
| 301 |
+
|
| 302 |
+
CREATE TABLE rule_set_members (
|
| 303 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 304 |
+
set_id UUID NOT NULL REFERENCES rule_sets(id) ON DELETE CASCADE,
|
| 305 |
+
rule_id UUID NOT NULL REFERENCES rules(id) ON DELETE CASCADE,
|
| 306 |
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
| 307 |
+
weight DECIMAL(5,2) DEFAULT 1.0,
|
| 308 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 309 |
+
UNIQUE(set_id, rule_id)
|
| 310 |
+
);
|
| 311 |
+
|
| 312 |
+
-- ============================================================
|
| 313 |
+
-- SCHEMA: FIELD CATALOG (Dynamic Field Mapping)
|
| 314 |
+
-- ============================================================
|
| 315 |
+
|
| 316 |
+
CREATE TABLE field_catalog (
|
| 317 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 318 |
+
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = global
|
| 319 |
+
field_path VARCHAR(500) NOT NULL, -- e.g., "bureau.cibil_score", "applicant.age"
|
| 320 |
+
display_name VARCHAR(200) NOT NULL,
|
| 321 |
+
description TEXT,
|
| 322 |
+
data_type VARCHAR(50) NOT NULL, -- NUMBER, STRING, BOOLEAN, DATE, ARRAY, OBJECT
|
| 323 |
+
category VARCHAR(100), -- BUREAU, INCOME, PERSONAL, VEHICLE, etc.
|
| 324 |
+
sample_values JSONB,
|
| 325 |
+
validation_rules JSONB,
|
| 326 |
+
is_system_field BOOLEAN NOT NULL DEFAULT FALSE,
|
| 327 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 328 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 329 |
+
UNIQUE(tenant_id, field_path)
|
| 330 |
+
);
|
| 331 |
+
|
| 332 |
+
CREATE TABLE custom_functions (
|
| 333 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 334 |
+
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
| 335 |
+
function_name VARCHAR(200) NOT NULL,
|
| 336 |
+
display_name VARCHAR(200) NOT NULL,
|
| 337 |
+
description TEXT,
|
| 338 |
+
category VARCHAR(100),
|
| 339 |
+
return_type VARCHAR(50) NOT NULL,
|
| 340 |
+
parameters JSONB NOT NULL DEFAULT '[]'::JSONB,
|
| 341 |
+
implementation TEXT NOT NULL, -- C# expression or script
|
| 342 |
+
is_system_fn BOOLEAN NOT NULL DEFAULT FALSE,
|
| 343 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 344 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 345 |
+
UNIQUE(tenant_id, function_name)
|
| 346 |
+
);
|
| 347 |
+
|
| 348 |
+
-- ============================================================
|
| 349 |
+
-- SCHEMA: EXECUTION ENGINE
|
| 350 |
+
-- ============================================================
|
| 351 |
+
|
| 352 |
+
CREATE TABLE execution_requests (
|
| 353 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 354 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
| 355 |
+
correlation_id VARCHAR(200) UNIQUE,
|
| 356 |
+
application_id VARCHAR(200),
|
| 357 |
+
product_code VARCHAR(100),
|
| 358 |
+
branch_code VARCHAR(100),
|
| 359 |
+
stage_code VARCHAR(100),
|
| 360 |
+
rule_set_id UUID REFERENCES rule_sets(id),
|
| 361 |
+
input_payload JSONB NOT NULL,
|
| 362 |
+
input_hash VARCHAR(64),
|
| 363 |
+
status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, PROCESSING, COMPLETED, FAILED
|
| 364 |
+
priority INTEGER NOT NULL DEFAULT 5,
|
| 365 |
+
source_system VARCHAR(200),
|
| 366 |
+
api_key_id UUID REFERENCES api_keys(id),
|
| 367 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 368 |
+
processing_started_at TIMESTAMPTZ,
|
| 369 |
+
completed_at TIMESTAMPTZ,
|
| 370 |
+
processing_ms INTEGER
|
| 371 |
+
);
|
| 372 |
+
|
| 373 |
+
CREATE TABLE execution_results (
|
| 374 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 375 |
+
request_id UUID NOT NULL REFERENCES execution_requests(id) ON DELETE CASCADE,
|
| 376 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
| 377 |
+
final_decision VARCHAR(50) NOT NULL, -- APPROVE, REJECT, DEVIATION, REFER, PENDING
|
| 378 |
+
risk_score DECIMAL(5,2),
|
| 379 |
+
risk_category VARCHAR(50), -- LOW, MEDIUM, HIGH, CRITICAL
|
| 380 |
+
traffic_light VARCHAR(10), -- GREEN, AMBER, RED
|
| 381 |
+
total_rules_evaluated INTEGER NOT NULL DEFAULT 0,
|
| 382 |
+
rules_passed INTEGER NOT NULL DEFAULT 0,
|
| 383 |
+
rules_failed INTEGER NOT NULL DEFAULT 0,
|
| 384 |
+
rules_skipped INTEGER NOT NULL DEFAULT 0,
|
| 385 |
+
deviations_count INTEGER NOT NULL DEFAULT 0,
|
| 386 |
+
execution_ms INTEGER,
|
| 387 |
+
rule_results JSONB NOT NULL DEFAULT '[]'::JSONB,
|
| 388 |
+
field_values JSONB NOT NULL DEFAULT '{}'::JSONB,
|
| 389 |
+
ai_summary TEXT,
|
| 390 |
+
ai_analysis JSONB,
|
| 391 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 392 |
+
);
|
| 393 |
+
|
| 394 |
+
CREATE TABLE rule_execution_details (
|
| 395 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 396 |
+
result_id UUID NOT NULL REFERENCES execution_results(id) ON DELETE CASCADE,
|
| 397 |
+
rule_id UUID NOT NULL REFERENCES rules(id),
|
| 398 |
+
rule_code VARCHAR(200) NOT NULL,
|
| 399 |
+
rule_name VARCHAR(500) NOT NULL,
|
| 400 |
+
version_number INTEGER NOT NULL,
|
| 401 |
+
execution_order INTEGER NOT NULL,
|
| 402 |
+
is_matched BOOLEAN NOT NULL,
|
| 403 |
+
conditions_evaluated JSONB NOT NULL DEFAULT '[]'::JSONB,
|
| 404 |
+
actions_executed JSONB NOT NULL DEFAULT '[]'::JSONB,
|
| 405 |
+
execution_ms INTEGER,
|
| 406 |
+
error_message TEXT,
|
| 407 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 408 |
+
);
|
| 409 |
+
|
| 410 |
+
-- ============================================================
|
| 411 |
+
-- SCHEMA: DEVIATIONS
|
| 412 |
+
-- ============================================================
|
| 413 |
+
|
| 414 |
+
CREATE TABLE deviation_types (
|
| 415 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 416 |
+
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
| 417 |
+
deviation_code VARCHAR(200) NOT NULL,
|
| 418 |
+
deviation_name VARCHAR(500) NOT NULL,
|
| 419 |
+
category VARCHAR(100),
|
| 420 |
+
default_severity VARCHAR(50) NOT NULL DEFAULT 'MEDIUM', -- LOW, MEDIUM, HIGH, CRITICAL
|
| 421 |
+
description TEXT,
|
| 422 |
+
recommended_action TEXT,
|
| 423 |
+
requires_approval BOOLEAN NOT NULL DEFAULT FALSE,
|
| 424 |
+
approver_role VARCHAR(200),
|
| 425 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 426 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 427 |
+
UNIQUE(tenant_id, deviation_code)
|
| 428 |
+
);
|
| 429 |
+
|
| 430 |
+
CREATE TABLE execution_deviations (
|
| 431 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 432 |
+
result_id UUID NOT NULL REFERENCES execution_results(id) ON DELETE CASCADE,
|
| 433 |
+
rule_id UUID REFERENCES rules(id),
|
| 434 |
+
deviation_type_id UUID REFERENCES deviation_types(id),
|
| 435 |
+
deviation_code VARCHAR(200) NOT NULL,
|
| 436 |
+
deviation_name VARCHAR(500) NOT NULL,
|
| 437 |
+
severity VARCHAR(50) NOT NULL,
|
| 438 |
+
reason TEXT NOT NULL,
|
| 439 |
+
field_path VARCHAR(500),
|
| 440 |
+
actual_value TEXT,
|
| 441 |
+
expected_value TEXT,
|
| 442 |
+
recommended_action TEXT,
|
| 443 |
+
is_overridden BOOLEAN NOT NULL DEFAULT FALSE,
|
| 444 |
+
overridden_by UUID REFERENCES users(id),
|
| 445 |
+
override_reason TEXT,
|
| 446 |
+
override_at TIMESTAMPTZ,
|
| 447 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 448 |
+
);
|
| 449 |
+
|
| 450 |
+
-- ============================================================
|
| 451 |
+
-- SCHEMA: DECISION REPORTS
|
| 452 |
+
-- ============================================================
|
| 453 |
+
|
| 454 |
+
CREATE TABLE decision_reports (
|
| 455 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 456 |
+
request_id UUID NOT NULL UNIQUE REFERENCES execution_requests(id),
|
| 457 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
| 458 |
+
report_number VARCHAR(100) UNIQUE,
|
| 459 |
+
application_id VARCHAR(200),
|
| 460 |
+
product_code VARCHAR(100),
|
| 461 |
+
final_decision VARCHAR(50) NOT NULL,
|
| 462 |
+
risk_score DECIMAL(5,2),
|
| 463 |
+
risk_category VARCHAR(50),
|
| 464 |
+
traffic_light VARCHAR(10),
|
| 465 |
+
summary TEXT,
|
| 466 |
+
strengths TEXT[],
|
| 467 |
+
weaknesses TEXT[],
|
| 468 |
+
deviations_summary TEXT,
|
| 469 |
+
approval_recommendation TEXT,
|
| 470 |
+
rejection_reasons TEXT[],
|
| 471 |
+
additional_docs TEXT[],
|
| 472 |
+
underwriting_notes TEXT,
|
| 473 |
+
report_json JSONB,
|
| 474 |
+
pdf_storage_path TEXT,
|
| 475 |
+
excel_storage_path TEXT,
|
| 476 |
+
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 477 |
+
expires_at TIMESTAMPTZ
|
| 478 |
+
);
|
| 479 |
+
|
| 480 |
+
-- ============================================================
|
| 481 |
+
-- SCHEMA: SANDBOX & TESTING
|
| 482 |
+
-- ============================================================
|
| 483 |
+
|
| 484 |
+
CREATE TABLE sandbox_sessions (
|
| 485 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 486 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 487 |
+
session_name VARCHAR(200) NOT NULL,
|
| 488 |
+
rule_set_id UUID REFERENCES rule_sets(id),
|
| 489 |
+
test_payload JSONB NOT NULL,
|
| 490 |
+
result JSONB,
|
| 491 |
+
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
|
| 492 |
+
created_by UUID NOT NULL REFERENCES users(id),
|
| 493 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 494 |
+
executed_at TIMESTAMPTZ
|
| 495 |
+
);
|
| 496 |
+
|
| 497 |
+
-- ============================================================
|
| 498 |
+
-- SCHEMA: AI ENGINE
|
| 499 |
+
-- ============================================================
|
| 500 |
+
|
| 501 |
+
CREATE TABLE ai_prompts (
|
| 502 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 503 |
+
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
| 504 |
+
prompt_code VARCHAR(200) NOT NULL,
|
| 505 |
+
prompt_name VARCHAR(300) NOT NULL,
|
| 506 |
+
prompt_template TEXT NOT NULL,
|
| 507 |
+
model VARCHAR(100) DEFAULT 'gpt-4o',
|
| 508 |
+
temperature DECIMAL(3,2) DEFAULT 0.3,
|
| 509 |
+
max_tokens INTEGER DEFAULT 2000,
|
| 510 |
+
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
| 511 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 512 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 513 |
+
UNIQUE(tenant_id, prompt_code)
|
| 514 |
+
);
|
| 515 |
+
|
| 516 |
+
CREATE TABLE ai_generated_rules (
|
| 517 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 518 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 519 |
+
user_prompt TEXT NOT NULL,
|
| 520 |
+
generated_rule JSONB NOT NULL,
|
| 521 |
+
rule_id UUID REFERENCES rules(id),
|
| 522 |
+
model_used VARCHAR(100),
|
| 523 |
+
tokens_used INTEGER,
|
| 524 |
+
is_accepted BOOLEAN,
|
| 525 |
+
feedback TEXT,
|
| 526 |
+
created_by UUID NOT NULL REFERENCES users(id),
|
| 527 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 528 |
+
);
|
| 529 |
+
|
| 530 |
+
-- ============================================================
|
| 531 |
+
-- SCHEMA: AUDIT & COMPLIANCE
|
| 532 |
+
-- ============================================================
|
| 533 |
+
|
| 534 |
+
CREATE TABLE audit_logs (
|
| 535 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 536 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
| 537 |
+
user_id UUID REFERENCES users(id),
|
| 538 |
+
action VARCHAR(200) NOT NULL,
|
| 539 |
+
entity_type VARCHAR(100) NOT NULL,
|
| 540 |
+
entity_id VARCHAR(200),
|
| 541 |
+
old_values JSONB,
|
| 542 |
+
new_values JSONB,
|
| 543 |
+
ip_address INET,
|
| 544 |
+
user_agent TEXT,
|
| 545 |
+
request_id VARCHAR(200),
|
| 546 |
+
is_success BOOLEAN NOT NULL DEFAULT TRUE,
|
| 547 |
+
error_message TEXT,
|
| 548 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 549 |
+
);
|
| 550 |
+
|
| 551 |
+
CREATE TABLE rule_approvals (
|
| 552 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 553 |
+
rule_id UUID NOT NULL REFERENCES rules(id) ON DELETE CASCADE,
|
| 554 |
+
version_id UUID NOT NULL REFERENCES rule_versions(id) ON DELETE CASCADE,
|
| 555 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
| 556 |
+
requested_by UUID NOT NULL REFERENCES users(id),
|
| 557 |
+
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 558 |
+
reviewed_by UUID REFERENCES users(id),
|
| 559 |
+
reviewed_at TIMESTAMPTZ,
|
| 560 |
+
status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED
|
| 561 |
+
comments TEXT,
|
| 562 |
+
notification_sent BOOLEAN NOT NULL DEFAULT FALSE
|
| 563 |
+
);
|
| 564 |
+
|
| 565 |
+
-- ============================================================
|
| 566 |
+
-- SCHEMA: MARKETPLACE
|
| 567 |
+
-- ============================================================
|
| 568 |
+
|
| 569 |
+
CREATE TABLE marketplace_rules (
|
| 570 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 571 |
+
rule_code VARCHAR(200) UNIQUE NOT NULL,
|
| 572 |
+
rule_name VARCHAR(500) NOT NULL,
|
| 573 |
+
description TEXT,
|
| 574 |
+
category VARCHAR(100),
|
| 575 |
+
product_types TEXT[],
|
| 576 |
+
rule_definition JSONB NOT NULL,
|
| 577 |
+
author VARCHAR(200),
|
| 578 |
+
version VARCHAR(50),
|
| 579 |
+
downloads INTEGER NOT NULL DEFAULT 0,
|
| 580 |
+
rating DECIMAL(3,2) DEFAULT 0,
|
| 581 |
+
tags TEXT[],
|
| 582 |
+
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
|
| 583 |
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 584 |
+
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 585 |
+
);
|
| 586 |
+
|
| 587 |
+
CREATE TABLE marketplace_imports (
|
| 588 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 589 |
+
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
| 590 |
+
marketplace_rule_id UUID NOT NULL REFERENCES marketplace_rules(id),
|
| 591 |
+
imported_rule_id UUID REFERENCES rules(id),
|
| 592 |
+
imported_by UUID NOT NULL REFERENCES users(id),
|
| 593 |
+
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 594 |
+
);
|
| 595 |
+
|
| 596 |
+
-- ============================================================
|
| 597 |
+
-- INDEXES FOR PERFORMANCE
|
| 598 |
+
-- ============================================================
|
| 599 |
+
|
| 600 |
+
-- Tenant isolation indexes
|
| 601 |
+
CREATE INDEX idx_rules_tenant ON rules(tenant_id) WHERE is_active = TRUE;
|
| 602 |
+
CREATE INDEX idx_rules_tenant_category ON rules(tenant_id, category_id) WHERE is_active = TRUE;
|
| 603 |
+
CREATE INDEX idx_rules_tenant_type ON rules(tenant_id, rule_type) WHERE is_active = TRUE AND is_published = TRUE;
|
| 604 |
+
CREATE INDEX idx_rule_scopes_rule ON rule_scopes(rule_id);
|
| 605 |
+
CREATE INDEX idx_rule_scopes_tenant_type ON rule_scopes(tenant_id, scope_type, scope_value);
|
| 606 |
+
|
| 607 |
+
-- Execution performance indexes
|
| 608 |
+
CREATE INDEX idx_exec_requests_tenant_status ON execution_requests(tenant_id, status, created_at DESC);
|
| 609 |
+
CREATE INDEX idx_exec_requests_correlation ON execution_requests(correlation_id);
|
| 610 |
+
CREATE INDEX idx_exec_results_request ON execution_results(request_id);
|
| 611 |
+
CREATE INDEX idx_exec_results_tenant_decision ON execution_results(tenant_id, final_decision, created_at DESC);
|
| 612 |
+
CREATE INDEX idx_rule_exec_details_result ON rule_execution_details(result_id);
|
| 613 |
+
|
| 614 |
+
-- Audit indexes
|
| 615 |
+
CREATE INDEX idx_audit_tenant_entity ON audit_logs(tenant_id, entity_type, entity_id, created_at DESC);
|
| 616 |
+
CREATE INDEX idx_audit_tenant_user ON audit_logs(tenant_id, user_id, created_at DESC);
|
| 617 |
+
|
| 618 |
+
-- User lookup
|
| 619 |
+
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email) WHERE is_active = TRUE;
|
| 620 |
+
CREATE INDEX idx_users_tenant_username ON users(tenant_id, username) WHERE is_active = TRUE;
|
| 621 |
+
|
| 622 |
+
-- Field catalog full-text
|
| 623 |
+
CREATE INDEX idx_field_catalog_path ON field_catalog USING GIN(to_tsvector('english', field_path || ' ' || display_name));
|
| 624 |
+
|
| 625 |
+
-- Execution date range queries
|
| 626 |
+
CREATE INDEX idx_exec_requests_tenant_date ON execution_requests(tenant_id, created_at DESC);
|
| 627 |
+
CREATE INDEX idx_decision_reports_tenant ON decision_reports(tenant_id, generated_at DESC);
|
| 628 |
+
|
| 629 |
+
-- ============================================================
|
| 630 |
+
-- SEED DATA: SYSTEM PERMISSIONS
|
| 631 |
+
-- ============================================================
|
| 632 |
+
|
| 633 |
+
INSERT INTO permissions (permission_code, permission_name, module, description) VALUES
|
| 634 |
+
-- Rule Management
|
| 635 |
+
('RULE.VIEW', 'View Rules', 'RULE_ENGINE', 'View all rules'),
|
| 636 |
+
('RULE.CREATE', 'Create Rules', 'RULE_ENGINE', 'Create new rules'),
|
| 637 |
+
('RULE.EDIT', 'Edit Rules', 'RULE_ENGINE', 'Edit existing rules'),
|
| 638 |
+
('RULE.DELETE', 'Delete Rules', 'RULE_ENGINE', 'Delete rules'),
|
| 639 |
+
('RULE.PUBLISH', 'Publish Rules', 'RULE_ENGINE', 'Publish rules to production'),
|
| 640 |
+
('RULE.APPROVE', 'Approve Rules', 'RULE_ENGINE', 'Approve rule changes'),
|
| 641 |
+
('RULE.CLONE', 'Clone Rules', 'RULE_ENGINE', 'Clone existing rules'),
|
| 642 |
+
('RULE.VERSION', 'Manage Rule Versions', 'RULE_ENGINE', 'View and manage rule versions'),
|
| 643 |
+
-- Execution
|
| 644 |
+
('EXECUTION.VIEW', 'View Executions', 'EXECUTION', 'View execution results'),
|
| 645 |
+
('EXECUTION.EXECUTE', 'Execute Rules', 'EXECUTION', 'Execute rule engine via API'),
|
| 646 |
+
('EXECUTION.SANDBOX', 'Use Sandbox', 'EXECUTION', 'Use sandbox testing environment'),
|
| 647 |
+
-- Client Management
|
| 648 |
+
('CLIENT.VIEW', 'View Clients', 'CLIENT_MGMT', 'View client list'),
|
| 649 |
+
('CLIENT.CREATE', 'Create Clients', 'CLIENT_MGMT', 'Create new clients'),
|
| 650 |
+
('CLIENT.EDIT', 'Edit Clients', 'CLIENT_MGMT', 'Edit client configuration'),
|
| 651 |
+
('CLIENT.MANAGE_USERS', 'Manage Client Users', 'CLIENT_MGMT', 'Manage users within tenant'),
|
| 652 |
+
-- Reports
|
| 653 |
+
('REPORT.VIEW', 'View Reports', 'REPORTS', 'View decision reports'),
|
| 654 |
+
('REPORT.EXPORT', 'Export Reports', 'REPORTS', 'Export reports to PDF/Excel'),
|
| 655 |
+
-- AI Engine
|
| 656 |
+
('AI.GENERATE', 'AI Rule Generation', 'AI_ENGINE', 'Use AI to generate rules'),
|
| 657 |
+
('AI.ANALYSIS', 'AI Credit Analysis', 'AI_ENGINE', 'Access AI credit analysis'),
|
| 658 |
+
-- Admin
|
| 659 |
+
('ADMIN.FULL', 'Full Admin Access', 'ADMIN', 'Complete administrative access'),
|
| 660 |
+
('AUDIT.VIEW', 'View Audit Logs', 'AUDIT', 'Access audit trail');
|
| 661 |
+
|
| 662 |
+
-- ============================================================
|
| 663 |
+
-- SEED DATA: GLOBAL FIELD CATALOG
|
| 664 |
+
-- ============================================================
|
| 665 |
+
|
| 666 |
+
INSERT INTO field_catalog (field_path, display_name, data_type, category, is_system_field) VALUES
|
| 667 |
+
-- Personal
|
| 668 |
+
('applicant.age', 'Applicant Age', 'NUMBER', 'PERSONAL', TRUE),
|
| 669 |
+
('applicant.date_of_birth', 'Date of Birth', 'DATE', 'PERSONAL', TRUE),
|
| 670 |
+
('applicant.gender', 'Gender', 'STRING', 'PERSONAL', TRUE),
|
| 671 |
+
('applicant.pan_number', 'PAN Number', 'STRING', 'KYC', TRUE),
|
| 672 |
+
('applicant.aadhaar_number', 'Aadhaar Number', 'STRING', 'KYC', TRUE),
|
| 673 |
+
('applicant.mobile', 'Mobile Number', 'STRING', 'PERSONAL', TRUE),
|
| 674 |
+
('applicant.email', 'Email Address', 'STRING', 'PERSONAL', TRUE),
|
| 675 |
+
('applicant.marital_status', 'Marital Status', 'STRING', 'PERSONAL', TRUE),
|
| 676 |
+
('applicant.caste_category', 'Caste Category', 'STRING', 'PERSONAL', TRUE),
|
| 677 |
+
-- Employment
|
| 678 |
+
('employment.type', 'Employment Type', 'STRING', 'EMPLOYMENT', TRUE),
|
| 679 |
+
('employment.employer_name', 'Employer Name', 'STRING', 'EMPLOYMENT', TRUE),
|
| 680 |
+
('employment.monthly_income', 'Monthly Income', 'NUMBER', 'INCOME', TRUE),
|
| 681 |
+
('employment.annual_income', 'Annual Income', 'NUMBER', 'INCOME', TRUE),
|
| 682 |
+
('employment.vintage_months', 'Employment Vintage (Months)', 'NUMBER', 'EMPLOYMENT', TRUE),
|
| 683 |
+
-- Bureau
|
| 684 |
+
('bureau.cibil_score', 'CIBIL Score', 'NUMBER', 'BUREAU', TRUE),
|
| 685 |
+
('bureau.experian_score', 'Experian Score', 'NUMBER', 'BUREAU', TRUE),
|
| 686 |
+
('bureau.equifax_score', 'Equifax Score', 'NUMBER', 'BUREAU', TRUE),
|
| 687 |
+
('bureau.crif_score', 'CRIF Score', 'NUMBER', 'BUREAU', TRUE),
|
| 688 |
+
('bureau.max_dpd_24m', 'Max DPD (24 Months)', 'NUMBER', 'BUREAU', TRUE),
|
| 689 |
+
('bureau.max_dpd_12m', 'Max DPD (12 Months)', 'NUMBER', 'BUREAU', TRUE),
|
| 690 |
+
('bureau.total_active_loans', 'Total Active Loans', 'NUMBER', 'BUREAU', TRUE),
|
| 691 |
+
('bureau.unsecured_outstanding', 'Unsecured Outstanding Amount', 'NUMBER', 'BUREAU', TRUE),
|
| 692 |
+
('bureau.secured_outstanding', 'Secured Outstanding Amount', 'NUMBER', 'BUREAU', TRUE),
|
| 693 |
+
('bureau.total_emi_obligation', 'Total EMI Obligation', 'NUMBER', 'BUREAU', TRUE),
|
| 694 |
+
('bureau.written_off_amount', 'Written Off Amount', 'NUMBER', 'BUREAU', TRUE),
|
| 695 |
+
('bureau.suit_filed', 'Suit Filed', 'BOOLEAN', 'BUREAU', TRUE),
|
| 696 |
+
('bureau.wilful_defaulter', 'Wilful Defaulter', 'BOOLEAN', 'BUREAU', TRUE),
|
| 697 |
+
-- Income Ratios
|
| 698 |
+
('ratios.foir', 'FOIR (Fixed Obligation to Income Ratio)', 'NUMBER', 'RATIOS', TRUE),
|
| 699 |
+
('ratios.ltv', 'LTV (Loan to Value Ratio)', 'NUMBER', 'RATIOS', TRUE),
|
| 700 |
+
('ratios.dscr', 'DSCR (Debt Service Coverage Ratio)', 'NUMBER', 'RATIOS', TRUE),
|
| 701 |
+
-- Loan
|
| 702 |
+
('loan.amount', 'Loan Amount', 'NUMBER', 'LOAN', TRUE),
|
| 703 |
+
('loan.tenure_months', 'Loan Tenure (Months)', 'NUMBER', 'LOAN', TRUE),
|
| 704 |
+
('loan.emi', 'EMI Amount', 'NUMBER', 'LOAN', TRUE),
|
| 705 |
+
('loan.interest_rate', 'Interest Rate', 'NUMBER', 'LOAN', TRUE),
|
| 706 |
+
('loan.purpose', 'Loan Purpose', 'STRING', 'LOAN', TRUE),
|
| 707 |
+
-- Vehicle
|
| 708 |
+
('vehicle.type', 'Vehicle Type', 'STRING', 'VEHICLE', TRUE),
|
| 709 |
+
('vehicle.make', 'Vehicle Make', 'STRING', 'VEHICLE', TRUE),
|
| 710 |
+
('vehicle.model', 'Vehicle Model', 'STRING', 'VEHICLE', TRUE),
|
| 711 |
+
('vehicle.year', 'Vehicle Year', 'NUMBER', 'VEHICLE', TRUE),
|
| 712 |
+
('vehicle.age_years', 'Vehicle Age (Years)', 'NUMBER', 'VEHICLE', TRUE),
|
| 713 |
+
('vehicle.valuation', 'Vehicle Valuation', 'NUMBER', 'VEHICLE', TRUE),
|
| 714 |
+
('vehicle.registration_number', 'Registration Number', 'STRING', 'VEHICLE', TRUE),
|
| 715 |
+
-- FI
|
| 716 |
+
('fi.verified', 'FI Verified', 'BOOLEAN', 'FI', TRUE),
|
| 717 |
+
('fi.residence_type', 'Residence Type', 'STRING', 'FI', TRUE),
|
| 718 |
+
('fi.address_match', 'Address Match', 'BOOLEAN', 'FI', TRUE),
|
| 719 |
+
('fi.mobile_match', 'Mobile Match', 'BOOLEAN', 'FI', TRUE),
|
| 720 |
+
('fi.negative', 'FI Negative', 'BOOLEAN', 'FI', TRUE),
|
| 721 |
+
-- GST & ITR
|
| 722 |
+
('gst.turnover', 'GST Turnover', 'NUMBER', 'GST', TRUE),
|
| 723 |
+
('gst.filing_months', 'GST Filing Months', 'NUMBER', 'GST', TRUE),
|
| 724 |
+
('itr.gross_income', 'ITR Gross Income', 'NUMBER', 'ITR', TRUE),
|
| 725 |
+
('itr.net_income', 'ITR Net Income', 'NUMBER', 'ITR', TRUE),
|
| 726 |
+
('itr.years_filed', 'ITR Years Filed', 'NUMBER', 'ITR', TRUE),
|
| 727 |
+
-- Fraud
|
| 728 |
+
('fraud.score', 'Fraud Score', 'NUMBER', 'FRAUD', TRUE),
|
| 729 |
+
('fraud.blacklisted', 'Blacklisted', 'BOOLEAN', 'FRAUD', TRUE),
|
| 730 |
+
('fraud.device_score', 'Device Risk Score', 'NUMBER', 'FRAUD', TRUE);
|
| 731 |
+
|
| 732 |
+
-- ============================================================
|
| 733 |
+
-- SEED DATA: DEVIATION TYPES
|
| 734 |
+
-- ============================================================
|
| 735 |
+
|
| 736 |
+
INSERT INTO deviation_types (deviation_code, deviation_name, category, default_severity, description, recommended_action) VALUES
|
| 737 |
+
('LOW_BUREAU_SCORE', 'Low Bureau Score', 'BUREAU', 'HIGH', 'Bureau/CIBIL score below threshold', 'Obtain credit counseling letter or additional collateral'),
|
| 738 |
+
('HIGH_DPD', 'High DPD', 'BUREAU', 'HIGH', 'Days Past Due exceeds acceptable limit', 'Explain previous defaults with supporting docs'),
|
| 739 |
+
('HIGH_FOIR', 'High FOIR', 'INCOME', 'MEDIUM', 'Fixed Obligation to Income Ratio exceeds limit', 'Consider reducing loan amount or tenure'),
|
| 740 |
+
('INCOME_MISMATCH', 'Income Mismatch', 'INCOME', 'MEDIUM', 'Stated income does not match verified income', 'Provide additional income proof documents'),
|
| 741 |
+
('NEGATIVE_FI', 'Negative FI Report', 'FI', 'HIGH', 'Field Investigation report is negative', 'Second FI verification required'),
|
| 742 |
+
('ADDRESS_MISMATCH', 'Address Mismatch', 'FI', 'MEDIUM', 'Applicant address does not match records', 'Obtain additional address proof'),
|
| 743 |
+
('MOBILE_MISMATCH', 'Mobile Number Mismatch', 'FI', 'LOW', 'Mobile number not matching bureau records', 'Verify mobile ownership'),
|
| 744 |
+
('HIGH_LTV', 'High LTV', 'VALUATION', 'MEDIUM', 'Loan to Value ratio exceeds limit', 'Additional down payment required'),
|
| 745 |
+
('VEHICLE_AGE', 'Vehicle Age Deviation', 'VEHICLE', 'MEDIUM', 'Vehicle age exceeds permissible limit', 'Obtain extended warranty or reduce tenure'),
|
| 746 |
+
('MULTIPLE_LOANS', 'Multiple Active Loans', 'BUREAU', 'MEDIUM', 'High number of active loans', 'Review repayment capacity'),
|
| 747 |
+
('WRITTEN_OFF', 'Written Off Account', 'BUREAU', 'CRITICAL', 'Previous written off account found', 'NOC from previous lender required'),
|
| 748 |
+
('SUIT_FILED', 'Suit Filed', 'BUREAU', 'CRITICAL', 'Legal suit filed against applicant', 'Legal verification required'),
|
| 749 |
+
('AGE_DEVIATION', 'Age Deviation', 'PERSONAL', 'MEDIUM', 'Applicant age outside standard limits', 'Additional life insurance required'),
|
| 750 |
+
('LOW_VINTAGE', 'Low Employment Vintage', 'EMPLOYMENT', 'LOW', 'Employment tenure below minimum threshold', 'Previous employment proof required'),
|
| 751 |
+
('LOW_GST_FILING', 'Low GST Filing Compliance', 'GST', 'MEDIUM', 'GST filing not consistent', 'Last 12 months GST returns required'),
|
| 752 |
+
('FRAUD_RISK', 'Fraud Risk Detected', 'FRAUD', 'CRITICAL', 'High fraud risk score detected', 'Enhanced due diligence required');
|
| 753 |
+
|
| 754 |
+
-- ============================================================
|
| 755 |
+
-- TRIGGERS: AUTO UPDATE TIMESTAMPS
|
| 756 |
+
-- ============================================================
|
| 757 |
+
|
| 758 |
+
CREATE OR REPLACE FUNCTION update_updated_at()
|
| 759 |
+
RETURNS TRIGGER AS $$
|
| 760 |
+
BEGIN
|
| 761 |
+
NEW.updated_at = NOW();
|
| 762 |
+
RETURN NEW;
|
| 763 |
+
END;
|
| 764 |
+
$$ LANGUAGE plpgsql;
|
| 765 |
+
|
| 766 |
+
CREATE TRIGGER trg_tenants_updated_at BEFORE UPDATE ON tenants FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
| 767 |
+
CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
| 768 |
+
CREATE TRIGGER trg_roles_updated_at BEFORE UPDATE ON roles FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
| 769 |
+
CREATE TRIGGER trg_rules_updated_at BEFORE UPDATE ON rules FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
| 770 |
+
CREATE TRIGGER trg_rule_sets_updated_at BEFORE UPDATE ON rule_sets FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
| 771 |
+
|
| 772 |
+
-- Auto-generate report number
|
| 773 |
+
CREATE OR REPLACE FUNCTION generate_report_number()
|
| 774 |
+
RETURNS TRIGGER AS $$
|
| 775 |
+
BEGIN
|
| 776 |
+
NEW.report_number = 'BRE-' || TO_CHAR(NOW(), 'YYYYMMDD') || '-' || LPAD(nextval('report_seq')::TEXT, 6, '0');
|
| 777 |
+
RETURN NEW;
|
| 778 |
+
END;
|
| 779 |
+
$$ LANGUAGE plpgsql;
|
| 780 |
+
|
| 781 |
+
CREATE SEQUENCE IF NOT EXISTS report_seq START 1;
|
| 782 |
+
CREATE TRIGGER trg_decision_report_number BEFORE INSERT ON decision_reports FOR EACH ROW EXECUTE FUNCTION generate_report_number();
|
| 783 |
+
|
| 784 |
+
-- ============================================================
|
| 785 |
+
-- VIEWS FOR ANALYTICS
|
| 786 |
+
-- ============================================================
|
| 787 |
+
|
| 788 |
+
CREATE OR REPLACE VIEW v_execution_summary AS
|
| 789 |
+
SELECT
|
| 790 |
+
er.tenant_id,
|
| 791 |
+
DATE_TRUNC('day', eq.created_at) AS execution_date,
|
| 792 |
+
COUNT(*) AS total_executions,
|
| 793 |
+
SUM(CASE WHEN er.final_decision = 'APPROVE' THEN 1 ELSE 0 END) AS approved,
|
| 794 |
+
SUM(CASE WHEN er.final_decision = 'REJECT' THEN 1 ELSE 0 END) AS rejected,
|
| 795 |
+
SUM(CASE WHEN er.final_decision = 'DEVIATION' THEN 1 ELSE 0 END) AS deviations,
|
| 796 |
+
ROUND(AVG(er.risk_score), 2) AS avg_risk_score,
|
| 797 |
+
ROUND(AVG(eq.processing_ms), 0) AS avg_processing_ms,
|
| 798 |
+
SUM(CASE WHEN er.final_decision = 'APPROVE' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS approval_rate
|
| 799 |
+
FROM execution_results er
|
| 800 |
+
JOIN execution_requests eq ON eq.id = er.request_id
|
| 801 |
+
GROUP BY er.tenant_id, DATE_TRUNC('day', eq.created_at);
|
| 802 |
+
|
| 803 |
+
CREATE OR REPLACE VIEW v_rule_hit_analysis AS
|
| 804 |
+
SELECT
|
| 805 |
+
red.rule_id,
|
| 806 |
+
r.rule_code,
|
| 807 |
+
r.rule_name,
|
| 808 |
+
r.tenant_id,
|
| 809 |
+
COUNT(*) AS total_evaluations,
|
| 810 |
+
SUM(CASE WHEN red.is_matched THEN 1 ELSE 0 END) AS matched_count,
|
| 811 |
+
ROUND(SUM(CASE WHEN red.is_matched THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) AS hit_rate,
|
| 812 |
+
ROUND(AVG(red.execution_ms), 0) AS avg_execution_ms
|
| 813 |
+
FROM rule_execution_details red
|
| 814 |
+
JOIN rules r ON r.id = red.rule_id
|
| 815 |
+
GROUP BY red.rule_id, r.rule_code, r.rule_name, r.tenant_id;
|
| 816 |
+
|
| 817 |
+
CREATE OR REPLACE VIEW v_deviation_analysis AS
|
| 818 |
+
SELECT
|
| 819 |
+
ed.tenant_id,
|
| 820 |
+
ed.deviation_code,
|
| 821 |
+
ed.deviation_name,
|
| 822 |
+
ed.severity,
|
| 823 |
+
COUNT(*) AS occurrence_count,
|
| 824 |
+
SUM(CASE WHEN ed.is_overridden THEN 1 ELSE 0 END) AS override_count,
|
| 825 |
+
DATE_TRUNC('month', ed.created_at) AS month
|
| 826 |
+
FROM execution_deviations ed
|
| 827 |
+
GROUP BY ed.tenant_id, ed.deviation_code, ed.deviation_name, ed.severity, DATE_TRUNC('month', ed.created_at);
|
002_seed_admin.sql
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ============================================================
|
| 2 |
+
-- OPTIM AI BRE ENGINE - SEED: ADMIN TENANT + USER
|
| 3 |
+
-- Run AFTER 001_initial_schema.sql
|
| 4 |
+
-- Password: Admin@1234 (BCrypt hash below)
|
| 5 |
+
-- ============================================================
|
| 6 |
+
|
| 7 |
+
-- Step 1: Create the default SYSTEM tenant
|
| 8 |
+
INSERT INTO tenants (
|
| 9 |
+
id, tenant_code, tenant_name, display_name,
|
| 10 |
+
plan_type, max_rules, max_executions_per_day,
|
| 11 |
+
is_active, settings
|
| 12 |
+
) VALUES (
|
| 13 |
+
'a0000000-0000-0000-0000-000000000001',
|
| 14 |
+
'SYSTEM',
|
| 15 |
+
'OPTIM AI - System Tenant',
|
| 16 |
+
'System Admin',
|
| 17 |
+
'ENTERPRISE',
|
| 18 |
+
99999,
|
| 19 |
+
99999999,
|
| 20 |
+
TRUE,
|
| 21 |
+
'{"isSystem": true}'::jsonb
|
| 22 |
+
) ON CONFLICT (tenant_code) DO NOTHING;
|
| 23 |
+
|
| 24 |
+
-- Step 2: Create SUPER_ADMIN role for the system tenant
|
| 25 |
+
INSERT INTO roles (
|
| 26 |
+
id, tenant_id, role_code, role_name, description,
|
| 27 |
+
is_system_role, is_active
|
| 28 |
+
) VALUES (
|
| 29 |
+
'b0000000-0000-0000-0000-000000000001',
|
| 30 |
+
'a0000000-0000-0000-0000-000000000001',
|
| 31 |
+
'SUPER_ADMIN',
|
| 32 |
+
'Super Administrator',
|
| 33 |
+
'Full system access - all permissions',
|
| 34 |
+
TRUE, TRUE
|
| 35 |
+
) ON CONFLICT DO NOTHING;
|
| 36 |
+
|
| 37 |
+
-- Step 3: Grant ALL permissions to SUPER_ADMIN
|
| 38 |
+
INSERT INTO role_permissions (role_id, permission_id)
|
| 39 |
+
SELECT
|
| 40 |
+
'b0000000-0000-0000-0000-000000000001',
|
| 41 |
+
p.id
|
| 42 |
+
FROM permissions p
|
| 43 |
+
ON CONFLICT DO NOTHING;
|
| 44 |
+
|
| 45 |
+
-- Step 4: Create the admin user
|
| 46 |
+
-- Email: admin@optimai.in
|
| 47 |
+
-- Password: Admin@1234
|
| 48 |
+
-- BCrypt hash generated with work factor 12
|
| 49 |
+
INSERT INTO users (
|
| 50 |
+
id, tenant_id, email, username, password_hash,
|
| 51 |
+
full_name, designation, department,
|
| 52 |
+
is_active, is_email_verified
|
| 53 |
+
) VALUES (
|
| 54 |
+
'c0000000-0000-0000-0000-000000000001',
|
| 55 |
+
'a0000000-0000-0000-0000-000000000001',
|
| 56 |
+
'admin@optimai.in',
|
| 57 |
+
'admin',
|
| 58 |
+
'$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/Lewjoc.MiG6L6bHQW',
|
| 59 |
+
'System Administrator',
|
| 60 |
+
'Platform Administrator',
|
| 61 |
+
'Technology',
|
| 62 |
+
TRUE,
|
| 63 |
+
TRUE
|
| 64 |
+
) ON CONFLICT (tenant_id, email) DO NOTHING;
|
| 65 |
+
|
| 66 |
+
-- Step 5: Assign SUPER_ADMIN role to admin user
|
| 67 |
+
INSERT INTO user_roles (user_id, role_id)
|
| 68 |
+
VALUES (
|
| 69 |
+
'c0000000-0000-0000-0000-000000000001',
|
| 70 |
+
'b0000000-0000-0000-0000-000000000001'
|
| 71 |
+
) ON CONFLICT DO NOTHING;
|
| 72 |
+
|
| 73 |
+
-- Step 6: Create a DEMO tenant for testing multi-tenancy
|
| 74 |
+
INSERT INTO tenants (
|
| 75 |
+
id, tenant_code, tenant_name, display_name,
|
| 76 |
+
plan_type, max_rules, max_executions_per_day, is_active, settings
|
| 77 |
+
) VALUES (
|
| 78 |
+
'a0000000-0000-0000-0000-000000000002',
|
| 79 |
+
'DEMO_BANK',
|
| 80 |
+
'Demo Bank Ltd',
|
| 81 |
+
'Demo Bank',
|
| 82 |
+
'ENTERPRISE', 500, 100000, TRUE,
|
| 83 |
+
'{"productTypes": ["VEHICLE_LOAN","TRACTOR_LOAN","MSME"]}'::jsonb
|
| 84 |
+
) ON CONFLICT (tenant_code) DO NOTHING;
|
| 85 |
+
|
| 86 |
+
-- Step 7: Create CREDIT_MANAGER role for Demo Bank
|
| 87 |
+
INSERT INTO roles (id, tenant_id, role_code, role_name, is_system_role, is_active)
|
| 88 |
+
VALUES (
|
| 89 |
+
'b0000000-0000-0000-0000-000000000002',
|
| 90 |
+
'a0000000-0000-0000-0000-000000000002',
|
| 91 |
+
'CREDIT_MANAGER', 'Credit Manager', FALSE, TRUE
|
| 92 |
+
) ON CONFLICT DO NOTHING;
|
| 93 |
+
|
| 94 |
+
-- Step 8: Grant rule permissions to CREDIT_MANAGER
|
| 95 |
+
INSERT INTO role_permissions (role_id, permission_id)
|
| 96 |
+
SELECT 'b0000000-0000-0000-0000-000000000002', p.id
|
| 97 |
+
FROM permissions p
|
| 98 |
+
WHERE p.permission_code IN (
|
| 99 |
+
'RULE.VIEW','RULE.CREATE','RULE.EDIT','RULE.CLONE',
|
| 100 |
+
'EXECUTION.VIEW','EXECUTION.EXECUTE','EXECUTION.SANDBOX',
|
| 101 |
+
'REPORT.VIEW','REPORT.EXPORT','AI.GENERATE','AI.ANALYSIS',
|
| 102 |
+
'AUDIT.VIEW'
|
| 103 |
+
) ON CONFLICT DO NOTHING;
|
| 104 |
+
|
| 105 |
+
-- Step 9: Create demo user for Demo Bank
|
| 106 |
+
-- Email: demo@demobank.in
|
| 107 |
+
-- Password: Demo@1234
|
| 108 |
+
INSERT INTO users (
|
| 109 |
+
id, tenant_id, email, username, password_hash,
|
| 110 |
+
full_name, designation, department, is_active, is_email_verified
|
| 111 |
+
) VALUES (
|
| 112 |
+
'c0000000-0000-0000-0000-000000000002',
|
| 113 |
+
'a0000000-0000-0000-0000-000000000002',
|
| 114 |
+
'demo@demobank.in',
|
| 115 |
+
'demo_credit',
|
| 116 |
+
'$2a$12$9k.GJjJDiZqSVhWNi3W4C.F.5XknZ3Nuo8hHE1t3L3XuoUgJAOxFW',
|
| 117 |
+
'Demo Credit Manager',
|
| 118 |
+
'Credit Manager',
|
| 119 |
+
'Credit Department',
|
| 120 |
+
TRUE, TRUE
|
| 121 |
+
) ON CONFLICT (tenant_id, email) DO NOTHING;
|
| 122 |
+
|
| 123 |
+
-- Step 10: Assign CREDIT_MANAGER role to demo user
|
| 124 |
+
INSERT INTO user_roles (user_id, role_id)
|
| 125 |
+
VALUES (
|
| 126 |
+
'c0000000-0000-0000-0000-000000000002',
|
| 127 |
+
'b0000000-0000-0000-0000-000000000002'
|
| 128 |
+
) ON CONFLICT DO NOTHING;
|
| 129 |
+
|
| 130 |
+
-- Step 11: Seed rule categories for Demo Bank
|
| 131 |
+
INSERT INTO rule_categories (id, tenant_id, category_code, category_name, icon, sort_order, is_active)
|
| 132 |
+
VALUES
|
| 133 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'ELIGIBILITY', 'Eligibility Rules', '✅', 1, TRUE),
|
| 134 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'BUREAU', 'Bureau Rules', '📊', 2, TRUE),
|
| 135 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'INCOME', 'Income Rules', '💰', 3, TRUE),
|
| 136 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'VEHICLE', 'Vehicle Rules', '🚗', 4, TRUE),
|
| 137 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'FI', 'FI Rules', '🏠', 5, TRUE),
|
| 138 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'FRAUD', 'Fraud Rules', '🚨', 6, TRUE),
|
| 139 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'COMPLIANCE', 'Compliance Rules', '📋', 7, TRUE)
|
| 140 |
+
ON CONFLICT DO NOTHING;
|
| 141 |
+
|
| 142 |
+
-- Step 12: Seed loan stages for Demo Bank
|
| 143 |
+
INSERT INTO loan_stages (id, tenant_id, stage_code, stage_name, stage_order, is_active)
|
| 144 |
+
VALUES
|
| 145 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'LOGIN', 'Login Stage', 1, TRUE),
|
| 146 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'DEDUPE', 'De-Dupe Check', 2, TRUE),
|
| 147 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'BUREAU_PULL', 'Bureau Pull', 3, TRUE),
|
| 148 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'CREDIT_EVAL', 'Credit Evaluation', 4, TRUE),
|
| 149 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'FI', 'Field Investigation', 5, TRUE),
|
| 150 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'VALUATION', 'Vehicle Valuation', 6, TRUE),
|
| 151 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'FINAL_CREDIT', 'Final Credit Decision', 7, TRUE),
|
| 152 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'SANCTION', 'Sanction', 8, TRUE)
|
| 153 |
+
ON CONFLICT DO NOTHING;
|
| 154 |
+
|
| 155 |
+
-- Step 13: Seed products for Demo Bank
|
| 156 |
+
INSERT INTO products (id, tenant_id, product_code, product_name, product_type, is_active, config)
|
| 157 |
+
VALUES
|
| 158 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'VL', 'Vehicle Loan', 'VEHICLE_LOAN', TRUE, '{}'::jsonb),
|
| 159 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'TL', 'Tractor Loan', 'TRACTOR_LOAN', TRUE, '{}'::jsonb),
|
| 160 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'AL', 'Auto Loan', 'AUTO_LOAN', TRUE, '{}'::jsonb),
|
| 161 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'CVL', 'Commercial Vehicle Loan', 'CV_LOAN', TRUE, '{}'::jsonb),
|
| 162 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'MSME', 'MSME Loan', 'MSME', TRUE, '{}'::jsonb),
|
| 163 |
+
(uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'PL', 'Personal Loan', 'PERSONAL_LOAN', TRUE, '{}'::jsonb)
|
| 164 |
+
ON CONFLICT DO NOTHING;
|
| 165 |
+
|
| 166 |
+
-- ============================================================
|
| 167 |
+
-- VERIFICATION QUERIES (run to confirm seed data)
|
| 168 |
+
-- ============================================================
|
| 169 |
+
-- SELECT * FROM tenants;
|
| 170 |
+
-- SELECT u.email, u.full_name, r.role_name FROM users u
|
| 171 |
+
-- JOIN user_roles ur ON ur.user_id = u.id
|
| 172 |
+
-- JOIN roles r ON r.id = ur.role_id;
|
| 173 |
+
-- SELECT COUNT(*) FROM permissions;
|
01_schema.sh
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# ==============================================================
|
| 3 |
+
# OPTIM AI BRE Engine — PostgreSQL First-Run Init Script
|
| 4 |
+
# Runs automatically when the postgres volume is empty.
|
| 5 |
+
# Applies schema + seed data in order.
|
| 6 |
+
# ==============================================================
|
| 7 |
+
set -e
|
| 8 |
+
|
| 9 |
+
echo "================================================"
|
| 10 |
+
echo "OPTIM AI BRE — Initializing PostgreSQL schema..."
|
| 11 |
+
echo "================================================"
|
| 12 |
+
|
| 13 |
+
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
| 14 |
+
|
| 15 |
+
-- Enable required extensions
|
| 16 |
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
| 17 |
+
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
| 18 |
+
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
| 19 |
+
|
| 20 |
+
EOSQL
|
| 21 |
+
|
| 22 |
+
echo "Extensions created. Running migration 001..."
|
| 23 |
+
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
|
| 24 |
+
-f /docker-entrypoint-initdb.d/migrations/001_initial_schema.sql
|
| 25 |
+
|
| 26 |
+
echo "Running migration 002 (seed data)..."
|
| 27 |
+
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
|
| 28 |
+
-f /docker-entrypoint-initdb.d/migrations/002_seed_admin.sql
|
| 29 |
+
|
| 30 |
+
echo "================================================"
|
| 31 |
+
echo "Database initialization complete!"
|
| 32 |
+
echo "Admin login: admin@optimai.in / Admin@1234"
|
| 33 |
+
echo "Demo login: demo@demobank.in / Demo@1234"
|
| 34 |
+
echo "================================================"
|
ActionEditor.tsx
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React from 'react'
|
| 4 |
+
import type { RuleAction, ActionType, Decision, RiskCategory, TrafficLight, Severity } from '@/types'
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
action: RuleAction
|
| 8 |
+
index: number
|
| 9 |
+
onChange: (action: RuleAction) => void
|
| 10 |
+
onDelete: () => void
|
| 11 |
+
readOnly?: boolean
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const ACTION_CONFIGS: Record<ActionType, {
|
| 15 |
+
label: string
|
| 16 |
+
icon: string
|
| 17 |
+
color: string
|
| 18 |
+
valueType: 'decision' | 'risk' | 'traffic_light' | 'text' | 'deviation' | 'score'
|
| 19 |
+
description: string
|
| 20 |
+
}> = {
|
| 21 |
+
SET_DECISION: {
|
| 22 |
+
label: 'Set Decision',
|
| 23 |
+
icon: '⚖️',
|
| 24 |
+
color: 'bg-red-50 border-red-200',
|
| 25 |
+
valueType: 'decision',
|
| 26 |
+
description: 'Sets the final loan decision',
|
| 27 |
+
},
|
| 28 |
+
SET_RISK: {
|
| 29 |
+
label: 'Set Risk Level',
|
| 30 |
+
icon: '⚠️',
|
| 31 |
+
color: 'bg-yellow-50 border-yellow-200',
|
| 32 |
+
valueType: 'risk',
|
| 33 |
+
description: 'Sets the risk category',
|
| 34 |
+
},
|
| 35 |
+
SET_TRAFFIC_LIGHT: {
|
| 36 |
+
label: 'Set Traffic Light',
|
| 37 |
+
icon: '🚦',
|
| 38 |
+
color: 'bg-green-50 border-green-200',
|
| 39 |
+
valueType: 'traffic_light',
|
| 40 |
+
description: 'Sets the visual status indicator',
|
| 41 |
+
},
|
| 42 |
+
ADD_DEVIATION: {
|
| 43 |
+
label: 'Add Deviation',
|
| 44 |
+
icon: '📋',
|
| 45 |
+
color: 'bg-orange-50 border-orange-200',
|
| 46 |
+
valueType: 'deviation',
|
| 47 |
+
description: 'Flags a policy deviation',
|
| 48 |
+
},
|
| 49 |
+
SET_FIELD: {
|
| 50 |
+
label: 'Set Output Field',
|
| 51 |
+
icon: '📝',
|
| 52 |
+
color: 'bg-blue-50 border-blue-200',
|
| 53 |
+
valueType: 'text',
|
| 54 |
+
description: 'Sets a custom output field value',
|
| 55 |
+
},
|
| 56 |
+
ADD_TAG: {
|
| 57 |
+
label: 'Add Tag',
|
| 58 |
+
icon: '🏷️',
|
| 59 |
+
color: 'bg-purple-50 border-purple-200',
|
| 60 |
+
valueType: 'text',
|
| 61 |
+
description: 'Adds a tag to the decision',
|
| 62 |
+
},
|
| 63 |
+
SET_SCORE: {
|
| 64 |
+
label: 'Set Risk Score',
|
| 65 |
+
icon: '📊',
|
| 66 |
+
color: 'bg-teal-50 border-teal-200',
|
| 67 |
+
valueType: 'score',
|
| 68 |
+
description: 'Sets or adjusts the risk score (0-100)',
|
| 69 |
+
},
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export function ActionEditor({ action, index, onChange, onDelete, readOnly }: Props) {
|
| 73 |
+
const config = ACTION_CONFIGS[action.type]
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
<div className={`flex items-center gap-3 rounded-xl border-2 p-3 ${config.color}`}>
|
| 77 |
+
{/* Action Number */}
|
| 78 |
+
<div className="w-6 h-6 rounded-full bg-white border border-gray-200 flex items-center justify-center text-xs font-bold text-gray-500">
|
| 79 |
+
{index + 1}
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
{/* Icon */}
|
| 83 |
+
<span className="text-xl" role="img">{config.icon}</span>
|
| 84 |
+
|
| 85 |
+
{/* Type Selector */}
|
| 86 |
+
<select
|
| 87 |
+
disabled={readOnly}
|
| 88 |
+
value={action.type}
|
| 89 |
+
onChange={(e) => onChange({ ...action, type: e.target.value as ActionType, value: '' })}
|
| 90 |
+
className="text-sm font-medium border border-transparent bg-white/70 rounded-lg px-2 py-1 focus:outline-none focus:border-blue-400"
|
| 91 |
+
>
|
| 92 |
+
{Object.entries(ACTION_CONFIGS).map(([type, cfg]) => (
|
| 93 |
+
<option key={type} value={type}>{cfg.label}</option>
|
| 94 |
+
))}
|
| 95 |
+
</select>
|
| 96 |
+
|
| 97 |
+
{/* Value Input */}
|
| 98 |
+
{config.valueType === 'decision' && (
|
| 99 |
+
<select
|
| 100 |
+
disabled={readOnly}
|
| 101 |
+
value={action.value ?? 'REJECT'}
|
| 102 |
+
onChange={(e) => onChange({ ...action, value: e.target.value })}
|
| 103 |
+
className="text-sm font-semibold border border-white/70 bg-white rounded-lg px-3 py-1 focus:outline-none focus:border-blue-400"
|
| 104 |
+
>
|
| 105 |
+
{(['APPROVE', 'REJECT', 'DEVIATION', 'REFER'] as Decision[]).map((d) => (
|
| 106 |
+
<option key={d} value={d}>{d}</option>
|
| 107 |
+
))}
|
| 108 |
+
</select>
|
| 109 |
+
)}
|
| 110 |
+
|
| 111 |
+
{config.valueType === 'risk' && (
|
| 112 |
+
<select
|
| 113 |
+
disabled={readOnly}
|
| 114 |
+
value={action.value ?? 'HIGH'}
|
| 115 |
+
onChange={(e) => onChange({ ...action, value: e.target.value })}
|
| 116 |
+
className="text-sm font-semibold border border-white/70 bg-white rounded-lg px-3 py-1 focus:outline-none focus:border-blue-400"
|
| 117 |
+
>
|
| 118 |
+
{(['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] as RiskCategory[]).map((r) => (
|
| 119 |
+
<option key={r} value={r}>{r}</option>
|
| 120 |
+
))}
|
| 121 |
+
</select>
|
| 122 |
+
)}
|
| 123 |
+
|
| 124 |
+
{config.valueType === 'traffic_light' && (
|
| 125 |
+
<select
|
| 126 |
+
disabled={readOnly}
|
| 127 |
+
value={action.value ?? 'RED'}
|
| 128 |
+
onChange={(e) => onChange({ ...action, value: e.target.value })}
|
| 129 |
+
className="text-sm font-semibold border border-white/70 bg-white rounded-lg px-3 py-1 focus:outline-none focus:border-blue-400"
|
| 130 |
+
>
|
| 131 |
+
{(['GREEN', 'AMBER', 'RED'] as TrafficLight[]).map((t) => (
|
| 132 |
+
<option key={t} value={t}>{t}</option>
|
| 133 |
+
))}
|
| 134 |
+
</select>
|
| 135 |
+
)}
|
| 136 |
+
|
| 137 |
+
{config.valueType === 'deviation' && (
|
| 138 |
+
<div className="flex gap-2 flex-1">
|
| 139 |
+
<input
|
| 140 |
+
disabled={readOnly}
|
| 141 |
+
value={action.value ?? ''}
|
| 142 |
+
onChange={(e) => onChange({ ...action, value: e.target.value })}
|
| 143 |
+
placeholder="Deviation code (e.g., LOW_BUREAU_SCORE)"
|
| 144 |
+
className="flex-1 text-sm border border-white/70 bg-white rounded-lg px-3 py-1 focus:outline-none focus:border-blue-400 font-mono"
|
| 145 |
+
/>
|
| 146 |
+
<input
|
| 147 |
+
disabled={readOnly}
|
| 148 |
+
value={action.parameters?.severity ?? 'HIGH'}
|
| 149 |
+
onChange={(e) => onChange({ ...action, parameters: { ...action.parameters, severity: e.target.value } })}
|
| 150 |
+
placeholder="Severity"
|
| 151 |
+
className="w-24 text-sm border border-white/70 bg-white rounded-lg px-2 py-1 focus:outline-none focus:border-blue-400"
|
| 152 |
+
/>
|
| 153 |
+
</div>
|
| 154 |
+
)}
|
| 155 |
+
|
| 156 |
+
{config.valueType === 'text' && (
|
| 157 |
+
<div className="flex gap-2 flex-1">
|
| 158 |
+
{action.type === 'SET_FIELD' && (
|
| 159 |
+
<input
|
| 160 |
+
disabled={readOnly}
|
| 161 |
+
value={action.field ?? ''}
|
| 162 |
+
onChange={(e) => onChange({ ...action, field: e.target.value })}
|
| 163 |
+
placeholder="Field name"
|
| 164 |
+
className="w-40 text-sm border border-white/70 bg-white rounded-lg px-2 py-1 focus:outline-none focus:border-blue-400 font-mono"
|
| 165 |
+
/>
|
| 166 |
+
)}
|
| 167 |
+
<input
|
| 168 |
+
disabled={readOnly}
|
| 169 |
+
value={action.value ?? ''}
|
| 170 |
+
onChange={(e) => onChange({ ...action, value: e.target.value })}
|
| 171 |
+
placeholder={action.type === 'ADD_TAG' ? 'tag-name' : 'Value'}
|
| 172 |
+
className="flex-1 text-sm border border-white/70 bg-white rounded-lg px-3 py-1 focus:outline-none focus:border-blue-400"
|
| 173 |
+
/>
|
| 174 |
+
</div>
|
| 175 |
+
)}
|
| 176 |
+
|
| 177 |
+
{config.valueType === 'score' && (
|
| 178 |
+
<div className="flex items-center gap-2">
|
| 179 |
+
<input
|
| 180 |
+
disabled={readOnly}
|
| 181 |
+
type="number"
|
| 182 |
+
min={-100}
|
| 183 |
+
max={100}
|
| 184 |
+
value={action.value ?? ''}
|
| 185 |
+
onChange={(e) => onChange({ ...action, value: e.target.value })}
|
| 186 |
+
placeholder="0–100 or +/-offset"
|
| 187 |
+
className="w-40 text-sm border border-white/70 bg-white rounded-lg px-3 py-1 focus:outline-none focus:border-blue-400"
|
| 188 |
+
/>
|
| 189 |
+
<span className="text-xs text-gray-500">Prefix +/- for adjustment</span>
|
| 190 |
+
</div>
|
| 191 |
+
)}
|
| 192 |
+
|
| 193 |
+
{/* Description */}
|
| 194 |
+
<span className="text-xs text-gray-500 hidden xl:block">{config.description}</span>
|
| 195 |
+
|
| 196 |
+
{/* Delete */}
|
| 197 |
+
{!readOnly && (
|
| 198 |
+
<button
|
| 199 |
+
onClick={onDelete}
|
| 200 |
+
className="ml-auto text-gray-300 hover:text-red-500 transition-colors text-lg"
|
| 201 |
+
title="Delete action"
|
| 202 |
+
>
|
| 203 |
+
✕
|
| 204 |
+
</button>
|
| 205 |
+
)}
|
| 206 |
+
</div>
|
| 207 |
+
)
|
| 208 |
+
}
|
ActionExecutor.cs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.Extensions.Logging;
|
| 2 |
+
using OptimAI.BRE.RuleEngine.Domain;
|
| 3 |
+
using OptimAI.BRE.Shared.Domain;
|
| 4 |
+
|
| 5 |
+
namespace OptimAI.BRE.RuleEngine.Application;
|
| 6 |
+
|
| 7 |
+
public sealed class ActionExecutor : IActionExecutor
|
| 8 |
+
{
|
| 9 |
+
private readonly IDeviationTypeRepository _deviationTypeRepo;
|
| 10 |
+
private readonly ILogger<ActionExecutor> _logger;
|
| 11 |
+
|
| 12 |
+
public ActionExecutor(IDeviationTypeRepository deviationTypeRepo, ILogger<ActionExecutor> logger)
|
| 13 |
+
{
|
| 14 |
+
_deviationTypeRepo = deviationTypeRepo;
|
| 15 |
+
_logger = logger;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
public async Task ExecuteAsync(RuleAction action, ExecutionContext context, RuleExecutionState state)
|
| 19 |
+
{
|
| 20 |
+
try
|
| 21 |
+
{
|
| 22 |
+
switch (action.Type)
|
| 23 |
+
{
|
| 24 |
+
case ActionType.SetDecision:
|
| 25 |
+
ExecuteSetDecision(action, state);
|
| 26 |
+
break;
|
| 27 |
+
|
| 28 |
+
case ActionType.SetRisk:
|
| 29 |
+
ExecuteSetRisk(action, state);
|
| 30 |
+
break;
|
| 31 |
+
|
| 32 |
+
case ActionType.SetTrafficLight:
|
| 33 |
+
ExecuteSetTrafficLight(action, state);
|
| 34 |
+
break;
|
| 35 |
+
|
| 36 |
+
case ActionType.AddDeviation:
|
| 37 |
+
await ExecuteAddDeviationAsync(action, context, state);
|
| 38 |
+
break;
|
| 39 |
+
|
| 40 |
+
case ActionType.SetField:
|
| 41 |
+
ExecuteSetField(action, state);
|
| 42 |
+
break;
|
| 43 |
+
|
| 44 |
+
case ActionType.AddTag:
|
| 45 |
+
ExecuteAddTag(action, state);
|
| 46 |
+
break;
|
| 47 |
+
|
| 48 |
+
case ActionType.SetScore:
|
| 49 |
+
ExecuteSetScore(action, state);
|
| 50 |
+
break;
|
| 51 |
+
|
| 52 |
+
default:
|
| 53 |
+
_logger.LogWarning("Unknown action type: {ActionType}", action.Type);
|
| 54 |
+
break;
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
catch (Exception ex)
|
| 58 |
+
{
|
| 59 |
+
_logger.LogError(ex, "Action execution failed: {ActionType}", action.Type);
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
private static void ExecuteSetDecision(RuleAction action, RuleExecutionState state)
|
| 64 |
+
{
|
| 65 |
+
if (Enum.TryParse<Decision>(action.Value, true, out var decision))
|
| 66 |
+
{
|
| 67 |
+
// Higher severity decisions take priority: Reject > Deviation > Refer > Approve
|
| 68 |
+
var priority = new Dictionary<Decision, int>
|
| 69 |
+
{
|
| 70 |
+
[Decision.Reject] = 4,
|
| 71 |
+
[Decision.Deviation] = 3,
|
| 72 |
+
[Decision.Refer] = 2,
|
| 73 |
+
[Decision.Approve] = 1,
|
| 74 |
+
[Decision.Pending] = 0
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
if (priority.GetValueOrDefault(decision, 0) > priority.GetValueOrDefault(state.GlobalState.CurrentDecision, 0))
|
| 78 |
+
{
|
| 79 |
+
state.GlobalState.CurrentDecision = decision;
|
| 80 |
+
|
| 81 |
+
if (decision == Decision.Reject)
|
| 82 |
+
state.GlobalState.ShouldStop = false; // Continue collecting all rejection reasons
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
private static void ExecuteSetRisk(RuleAction action, RuleExecutionState state)
|
| 88 |
+
{
|
| 89 |
+
if (Enum.TryParse<RiskCategory>(action.Value, true, out var risk))
|
| 90 |
+
{
|
| 91 |
+
// Escalate risk level only
|
| 92 |
+
var currentRisk = state.GlobalState.OutputFields.GetValueOrDefault("_riskCategory") as string;
|
| 93 |
+
if (currentRisk == null || ShouldEscalate(currentRisk, risk.ToString()))
|
| 94 |
+
state.GlobalState.OutputFields["_riskCategory"] = risk.ToString();
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
private static void ExecuteSetTrafficLight(RuleAction action, RuleExecutionState state)
|
| 99 |
+
{
|
| 100 |
+
if (Enum.TryParse<TrafficLight>(action.Value, true, out var tl))
|
| 101 |
+
state.GlobalState.TrafficLight = tl;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
private async Task ExecuteAddDeviationAsync(RuleAction action, ExecutionContext context, RuleExecutionState state)
|
| 105 |
+
{
|
| 106 |
+
var deviationCode = action.Value ?? action.Parameters.GetValueOrDefault("code");
|
| 107 |
+
if (deviationCode == null) return;
|
| 108 |
+
|
| 109 |
+
var deviationType = await _deviationTypeRepo.FindByCodeAsync(context.TenantId, deviationCode);
|
| 110 |
+
|
| 111 |
+
var deviation = new ExecutionDeviation
|
| 112 |
+
{
|
| 113 |
+
TenantId = context.TenantId,
|
| 114 |
+
DeviationCode = deviationCode,
|
| 115 |
+
DeviationName = deviationType?.DeviationName ?? deviationCode,
|
| 116 |
+
Severity = deviationType?.DefaultSeverity ?? ParseSeverity(action.Parameters.GetValueOrDefault("severity")),
|
| 117 |
+
Reason = action.Parameters.GetValueOrDefault("reason") ?? deviationType?.Description ?? "Policy deviation detected",
|
| 118 |
+
FieldPath = action.Parameters.GetValueOrDefault("field"),
|
| 119 |
+
RecommendedAction = action.Parameters.GetValueOrDefault("action") ?? deviationType?.RecommendedAction,
|
| 120 |
+
DeviationTypeId = deviationType?.Id
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
// Resolve field values for context
|
| 124 |
+
if (deviation.FieldPath != null)
|
| 125 |
+
{
|
| 126 |
+
var val = context.Data.GetValue(deviation.FieldPath);
|
| 127 |
+
deviation.ActualValue = val?.ToString();
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
state.GlobalState.Deviations.Add(deviation);
|
| 131 |
+
|
| 132 |
+
// Automatically set decision to DEVIATION if not already REJECT
|
| 133 |
+
if (state.GlobalState.CurrentDecision != Decision.Reject)
|
| 134 |
+
state.GlobalState.CurrentDecision = Decision.Deviation;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
private static void ExecuteSetField(RuleAction action, RuleExecutionState state)
|
| 138 |
+
{
|
| 139 |
+
if (action.Field != null)
|
| 140 |
+
state.GlobalState.OutputFields[action.Field] = action.Value ?? "";
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
private static void ExecuteAddTag(RuleAction action, RuleExecutionState state)
|
| 144 |
+
{
|
| 145 |
+
if (action.Value != null && !state.GlobalState.Tags.Contains(action.Value))
|
| 146 |
+
state.GlobalState.Tags.Add(action.Value);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
private static void ExecuteSetScore(RuleAction action, RuleExecutionState state)
|
| 150 |
+
{
|
| 151 |
+
if (decimal.TryParse(action.Value, out var score))
|
| 152 |
+
{
|
| 153 |
+
// Score adjustments: can be absolute or relative (+10, -5)
|
| 154 |
+
if (action.Value!.StartsWith('+') || action.Value.StartsWith('-'))
|
| 155 |
+
state.GlobalState.RiskScore = Math.Clamp(state.GlobalState.RiskScore + score, 0, 100);
|
| 156 |
+
else
|
| 157 |
+
state.GlobalState.RiskScore = Math.Clamp(score, 0, 100);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
private static bool ShouldEscalate(string current, string incoming)
|
| 162 |
+
{
|
| 163 |
+
var order = new[] { "Low", "Medium", "High", "Critical" };
|
| 164 |
+
var currentIdx = Array.IndexOf(order, current);
|
| 165 |
+
var incomingIdx = Array.IndexOf(order, incoming);
|
| 166 |
+
return incomingIdx > currentIdx;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
private static Severity ParseSeverity(string? value) =>
|
| 170 |
+
Enum.TryParse<Severity>(value, true, out var s) ? s : Severity.Medium;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
public interface IDeviationTypeRepository
|
| 174 |
+
{
|
| 175 |
+
Task<DeviationType?> FindByCodeAsync(Guid tenantId, string code, CancellationToken ct = default);
|
| 176 |
+
Task<IReadOnlyList<DeviationType>> GetAllActiveAsync(Guid tenantId, CancellationToken ct = default);
|
| 177 |
+
}
|
AiAnalystPanel.tsx
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react'
|
| 4 |
+
import { useMutation } from '@tanstack/react-query'
|
| 5 |
+
import type { AiAnalysis, BREDecisionResponse, Deviation } from '@/types'
|
| 6 |
+
import { apiClient } from '@/lib/api-client'
|
| 7 |
+
|
| 8 |
+
interface Props {
|
| 9 |
+
decision: BREDecisionResponse
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function AiAnalystPanel({ decision }: Props) {
|
| 13 |
+
const [aiResult, setAiResult] = useState<AiAnalysis | null>(decision.aiAnalysis ?? null)
|
| 14 |
+
const [activeSection, setActiveSection] = useState<string>('summary')
|
| 15 |
+
|
| 16 |
+
const analyze = useMutation({
|
| 17 |
+
mutationFn: () =>
|
| 18 |
+
apiClient.post('/ai/analyze-credit', {
|
| 19 |
+
applicationData: {},
|
| 20 |
+
decision: decision.decision,
|
| 21 |
+
riskScore: decision.riskScore,
|
| 22 |
+
riskCategory: decision.riskCategory,
|
| 23 |
+
deviations: [],
|
| 24 |
+
}) as Promise<AiAnalysis>,
|
| 25 |
+
onSuccess: setAiResult,
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
const sections = [
|
| 29 |
+
{ id: 'summary', label: 'Risk Summary', icon: '📊' },
|
| 30 |
+
{ id: 'credit', label: 'Credit Analysis', icon: '💳' },
|
| 31 |
+
{ id: 'strengths', label: 'Strengths', icon: '💪' },
|
| 32 |
+
{ id: 'weaknesses', label: 'Weaknesses', icon: '⚠️' },
|
| 33 |
+
{ id: 'deviations', label: 'Deviations', icon: '📋' },
|
| 34 |
+
{ id: 'docs', label: 'Documents Required', icon: '📂' },
|
| 35 |
+
{ id: 'notes', label: 'Underwriting Notes', icon: '📝' },
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
| 40 |
+
{/* Header */}
|
| 41 |
+
<div className="bg-gradient-to-r from-violet-600 to-purple-700 px-6 py-4">
|
| 42 |
+
<div className="flex items-center justify-between">
|
| 43 |
+
<div className="flex items-center gap-3">
|
| 44 |
+
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center text-xl">🤖</div>
|
| 45 |
+
<div>
|
| 46 |
+
<h2 className="text-white font-bold text-lg">OPTIM AI Credit Analyst</h2>
|
| 47 |
+
<p className="text-purple-200 text-sm">AI-powered credit risk assessment</p>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
{!aiResult && (
|
| 51 |
+
<button
|
| 52 |
+
onClick={() => analyze.mutate()}
|
| 53 |
+
disabled={analyze.isPending}
|
| 54 |
+
className="px-4 py-2 bg-white text-purple-700 rounded-lg text-sm font-semibold hover:bg-purple-50 transition-colors disabled:opacity-60"
|
| 55 |
+
>
|
| 56 |
+
{analyze.isPending ? '⏳ Analyzing...' : '✨ Run AI Analysis'}
|
| 57 |
+
</button>
|
| 58 |
+
)}
|
| 59 |
+
{aiResult && (
|
| 60 |
+
<div className="flex items-center gap-2">
|
| 61 |
+
<div className="text-white/70 text-sm">Confidence:</div>
|
| 62 |
+
<div className="text-white font-bold">{Math.round(aiResult.confidenceScore * 100)}%</div>
|
| 63 |
+
</div>
|
| 64 |
+
)}
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
{/* Decision Summary Bar */}
|
| 69 |
+
<div className={`px-6 py-3 flex items-center gap-6 ${
|
| 70 |
+
decision.decision === 'APPROVE' ? 'bg-emerald-50 border-b border-emerald-100' :
|
| 71 |
+
decision.decision === 'REJECT' ? 'bg-red-50 border-b border-red-100' :
|
| 72 |
+
'bg-amber-50 border-b border-amber-100'
|
| 73 |
+
}`}>
|
| 74 |
+
<TrafficLightIndicator light={decision.trafficLight} />
|
| 75 |
+
<div className="flex gap-6 text-sm">
|
| 76 |
+
<div>
|
| 77 |
+
<span className="text-gray-500">Decision:</span>
|
| 78 |
+
<span className={`ml-2 font-bold ${
|
| 79 |
+
decision.decision === 'APPROVE' ? 'text-emerald-700' :
|
| 80 |
+
decision.decision === 'REJECT' ? 'text-red-700' : 'text-amber-700'
|
| 81 |
+
}`}>{decision.decision}</span>
|
| 82 |
+
</div>
|
| 83 |
+
<div>
|
| 84 |
+
<span className="text-gray-500">Risk Score:</span>
|
| 85 |
+
<span className="ml-2 font-bold text-gray-800">{decision.riskScore.toFixed(1)}/100</span>
|
| 86 |
+
</div>
|
| 87 |
+
<div>
|
| 88 |
+
<span className="text-gray-500">Risk:</span>
|
| 89 |
+
<RiskBadge category={decision.riskCategory} />
|
| 90 |
+
</div>
|
| 91 |
+
<div>
|
| 92 |
+
<span className="text-gray-500">Rules:</span>
|
| 93 |
+
<span className="ml-2 font-bold text-gray-800">
|
| 94 |
+
{decision.rulesPassed}/{decision.totalRulesEvaluated} matched
|
| 95 |
+
</span>
|
| 96 |
+
</div>
|
| 97 |
+
<div>
|
| 98 |
+
<span className="text-gray-500">Deviations:</span>
|
| 99 |
+
<span className="ml-2 font-bold text-amber-700">{decision.deviationsCount}</span>
|
| 100 |
+
</div>
|
| 101 |
+
<div>
|
| 102 |
+
<span className="text-gray-500">Time:</span>
|
| 103 |
+
<span className="ml-2 font-bold text-gray-800">{decision.executionMs}ms</span>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{aiResult ? (
|
| 109 |
+
<div className="flex h-[500px]">
|
| 110 |
+
{/* Sidebar Nav */}
|
| 111 |
+
<div className="w-48 bg-gray-50 border-r border-gray-100 py-4">
|
| 112 |
+
{sections.map((s) => (
|
| 113 |
+
<button
|
| 114 |
+
key={s.id}
|
| 115 |
+
onClick={() => setActiveSection(s.id)}
|
| 116 |
+
className={`w-full flex items-center gap-2 px-4 py-2.5 text-sm text-left transition-colors ${
|
| 117 |
+
activeSection === s.id
|
| 118 |
+
? 'bg-violet-50 text-violet-700 font-semibold border-r-2 border-violet-600'
|
| 119 |
+
: 'text-gray-600 hover:bg-gray-100'
|
| 120 |
+
}`}
|
| 121 |
+
>
|
| 122 |
+
<span>{s.icon}</span>
|
| 123 |
+
<span>{s.label}</span>
|
| 124 |
+
</button>
|
| 125 |
+
))}
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
{/* Content */}
|
| 129 |
+
<div className="flex-1 overflow-auto p-6">
|
| 130 |
+
{activeSection === 'summary' && (
|
| 131 |
+
<AiSection title="Risk Summary" icon="📊" content={aiResult.riskSummary} />
|
| 132 |
+
)}
|
| 133 |
+
{activeSection === 'credit' && (
|
| 134 |
+
<AiSection title="Credit Summary" icon="💳" content={aiResult.creditSummary} />
|
| 135 |
+
)}
|
| 136 |
+
{activeSection === 'strengths' && (
|
| 137 |
+
<ListSection title="Customer Strengths" icon="💪" items={aiResult.strengths} variant="success" />
|
| 138 |
+
)}
|
| 139 |
+
{activeSection === 'weaknesses' && (
|
| 140 |
+
<ListSection title="Customer Weaknesses" icon="⚠️" items={aiResult.weaknesses} variant="warning" />
|
| 141 |
+
)}
|
| 142 |
+
{activeSection === 'deviations' && (
|
| 143 |
+
<div>
|
| 144 |
+
<AiSection title="Deviation Summary" icon="📋" content={aiResult.deviationsSummary} />
|
| 145 |
+
{aiResult.rejectionReasons.length > 0 && (
|
| 146 |
+
<div className="mt-4">
|
| 147 |
+
<ListSection title="Rejection Reasons" icon="🚫" items={aiResult.rejectionReasons} variant="error" />
|
| 148 |
+
</div>
|
| 149 |
+
)}
|
| 150 |
+
</div>
|
| 151 |
+
)}
|
| 152 |
+
{activeSection === 'docs' && (
|
| 153 |
+
<ListSection title="Additional Documents Required" icon="📂" items={aiResult.additionalDocuments} variant="info" />
|
| 154 |
+
)}
|
| 155 |
+
{activeSection === 'notes' && (
|
| 156 |
+
<AiSection title="Underwriting Notes" icon="📝" content={aiResult.underwritingNotes} />
|
| 157 |
+
)}
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
) : (
|
| 161 |
+
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
| 162 |
+
<div className="text-5xl mb-4">🤖</div>
|
| 163 |
+
<p className="text-lg font-medium text-gray-500">AI Analysis Not Yet Run</p>
|
| 164 |
+
<p className="text-sm mt-1">Click "Run AI Analysis" to get comprehensive credit insights</p>
|
| 165 |
+
</div>
|
| 166 |
+
)}
|
| 167 |
+
</div>
|
| 168 |
+
)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
function AiSection({ title, icon, content }: { title: string; icon: string; content: string }) {
|
| 172 |
+
return (
|
| 173 |
+
<div>
|
| 174 |
+
<h3 className="flex items-center gap-2 text-base font-semibold text-gray-800 mb-3">
|
| 175 |
+
<span>{icon}</span> {title}
|
| 176 |
+
</h3>
|
| 177 |
+
<div className="bg-gray-50 rounded-xl p-4 text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">
|
| 178 |
+
{content || <span className="text-gray-400 italic">No content available</span>}
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
)
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
function ListSection({
|
| 185 |
+
title, icon, items, variant
|
| 186 |
+
}: {
|
| 187 |
+
title: string; icon: string; items: string[]; variant: 'success' | 'warning' | 'error' | 'info'
|
| 188 |
+
}) {
|
| 189 |
+
const styles = {
|
| 190 |
+
success: { dot: 'bg-emerald-500', bg: 'bg-emerald-50', border: 'border-emerald-100' },
|
| 191 |
+
warning: { dot: 'bg-amber-500', bg: 'bg-amber-50', border: 'border-amber-100' },
|
| 192 |
+
error: { dot: 'bg-red-500', bg: 'bg-red-50', border: 'border-red-100' },
|
| 193 |
+
info: { dot: 'bg-blue-500', bg: 'bg-blue-50', border: 'border-blue-100' },
|
| 194 |
+
}[variant]
|
| 195 |
+
|
| 196 |
+
return (
|
| 197 |
+
<div>
|
| 198 |
+
<h3 className="flex items-center gap-2 text-base font-semibold text-gray-800 mb-3">
|
| 199 |
+
<span>{icon}</span> {title}
|
| 200 |
+
</h3>
|
| 201 |
+
{items.length === 0 ? (
|
| 202 |
+
<p className="text-gray-400 text-sm italic">None identified</p>
|
| 203 |
+
) : (
|
| 204 |
+
<div className={`rounded-xl border ${styles.border} ${styles.bg} p-4 space-y-2`}>
|
| 205 |
+
{items.map((item, idx) => (
|
| 206 |
+
<div key={idx} className="flex items-start gap-2">
|
| 207 |
+
<div className={`w-2 h-2 rounded-full ${styles.dot} mt-1.5 flex-shrink-0`} />
|
| 208 |
+
<span className="text-sm text-gray-700">{item}</span>
|
| 209 |
+
</div>
|
| 210 |
+
))}
|
| 211 |
+
</div>
|
| 212 |
+
)}
|
| 213 |
+
</div>
|
| 214 |
+
)
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
function TrafficLightIndicator({ light }: { light: string }) {
|
| 218 |
+
return (
|
| 219 |
+
<div className="flex items-center gap-1.5">
|
| 220 |
+
{(['GREEN', 'AMBER', 'RED'] as const).map((l) => (
|
| 221 |
+
<div
|
| 222 |
+
key={l}
|
| 223 |
+
className={`w-4 h-4 rounded-full transition-all ${
|
| 224 |
+
l === light
|
| 225 |
+
? l === 'GREEN' ? 'bg-emerald-500 shadow-lg shadow-emerald-200' :
|
| 226 |
+
l === 'AMBER' ? 'bg-amber-500 shadow-lg shadow-amber-200' :
|
| 227 |
+
'bg-red-500 shadow-lg shadow-red-200'
|
| 228 |
+
: 'bg-gray-200'
|
| 229 |
+
}`}
|
| 230 |
+
/>
|
| 231 |
+
))}
|
| 232 |
+
</div>
|
| 233 |
+
)
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
function RiskBadge({ category }: { category: string }) {
|
| 237 |
+
const colors: Record<string, string> = {
|
| 238 |
+
LOW: 'ml-2 px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded text-xs font-semibold',
|
| 239 |
+
MEDIUM: 'ml-2 px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs font-semibold',
|
| 240 |
+
HIGH: 'ml-2 px-2 py-0.5 bg-orange-100 text-orange-700 rounded text-xs font-semibold',
|
| 241 |
+
CRITICAL: 'ml-2 px-2 py-0.5 bg-red-100 text-red-700 rounded text-xs font-semibold',
|
| 242 |
+
}
|
| 243 |
+
return <span className={colors[category] ?? colors.MEDIUM}>{category}</span>
|
| 244 |
+
}
|
AiController.cs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.AspNetCore.Authorization;
|
| 2 |
+
using Microsoft.AspNetCore.Mvc;
|
| 3 |
+
using OptimAI.BRE.AIEngine.Application;
|
| 4 |
+
using OptimAI.BRE.RuleEngine.Api;
|
| 5 |
+
using OptimAI.BRE.Shared.Domain;
|
| 6 |
+
|
| 7 |
+
namespace OptimAI.BRE.AIEngine.Api;
|
| 8 |
+
|
| 9 |
+
[ApiController]
|
| 10 |
+
[Route("api/v1/ai")]
|
| 11 |
+
[Authorize]
|
| 12 |
+
public sealed class AiController : ControllerBase
|
| 13 |
+
{
|
| 14 |
+
private readonly IAiCreditAnalystService _aiService;
|
| 15 |
+
private readonly IAiRuleGeneratorRepository _generatedRuleRepo;
|
| 16 |
+
private readonly ITenantContextAccessor _tenantAccessor;
|
| 17 |
+
private readonly IFieldCatalogRepository _fieldCatalogRepo;
|
| 18 |
+
private readonly ILogger<AiController> _logger;
|
| 19 |
+
|
| 20 |
+
public AiController(
|
| 21 |
+
IAiCreditAnalystService aiService,
|
| 22 |
+
IAiRuleGeneratorRepository generatedRuleRepo,
|
| 23 |
+
ITenantContextAccessor tenantAccessor,
|
| 24 |
+
IFieldCatalogRepository fieldCatalogRepo,
|
| 25 |
+
ILogger<AiController> logger)
|
| 26 |
+
{
|
| 27 |
+
_aiService = aiService;
|
| 28 |
+
_generatedRuleRepo = generatedRuleRepo;
|
| 29 |
+
_tenantAccessor = tenantAccessor;
|
| 30 |
+
_fieldCatalogRepo = fieldCatalogRepo;
|
| 31 |
+
_logger = logger;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/// <summary>
|
| 35 |
+
/// Generate BRE rule from natural language description using AI.
|
| 36 |
+
/// </summary>
|
| 37 |
+
[HttpPost("generate-rule")]
|
| 38 |
+
[Authorize(Policy = "AiGenerate")]
|
| 39 |
+
[ProducesResponseType(typeof(GeneratedRuleResponse), 200)]
|
| 40 |
+
public async Task<IActionResult> GenerateRule([FromBody] GenerateRuleRequest request, CancellationToken ct)
|
| 41 |
+
{
|
| 42 |
+
var availableFields = await _fieldCatalogRepo.GetFieldPathsAsync(_tenantAccessor.TenantId, ct);
|
| 43 |
+
|
| 44 |
+
var ruleDefinition = await _aiService.GenerateRuleFromPromptAsync(new RuleGenerationRequest
|
| 45 |
+
{
|
| 46 |
+
UserPrompt = request.Prompt,
|
| 47 |
+
ProductType = request.ProductType,
|
| 48 |
+
AvailableFields = availableFields,
|
| 49 |
+
TenantId = _tenantAccessor.TenantId,
|
| 50 |
+
CreatedBy = _tenantAccessor.UserId
|
| 51 |
+
}, ct);
|
| 52 |
+
|
| 53 |
+
if (ruleDefinition == null)
|
| 54 |
+
return BadRequest(new { error = "AI could not generate a rule from the provided description. Please be more specific." });
|
| 55 |
+
|
| 56 |
+
var savedId = await _generatedRuleRepo.SaveGeneratedRuleAsync(new AiGeneratedRuleRecord
|
| 57 |
+
{
|
| 58 |
+
TenantId = _tenantAccessor.TenantId,
|
| 59 |
+
UserPrompt = request.Prompt,
|
| 60 |
+
GeneratedRule = ruleDefinition,
|
| 61 |
+
CreatedBy = _tenantAccessor.UserId
|
| 62 |
+
}, ct);
|
| 63 |
+
|
| 64 |
+
return Ok(new GeneratedRuleResponse
|
| 65 |
+
{
|
| 66 |
+
GenerationId = savedId,
|
| 67 |
+
UserPrompt = request.Prompt,
|
| 68 |
+
RuleDefinition = ruleDefinition,
|
| 69 |
+
CanEdit = true,
|
| 70 |
+
Message = "Rule generated successfully. Review and save to rule engine."
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/// <summary>
|
| 75 |
+
/// Run AI credit analysis on execution result.
|
| 76 |
+
/// </summary>
|
| 77 |
+
[HttpPost("analyze-credit")]
|
| 78 |
+
[Authorize(Policy = "AiAnalysis")]
|
| 79 |
+
[ProducesResponseType(typeof(AiAnalysis), 200)]
|
| 80 |
+
public async Task<IActionResult> AnalyzeCredit([FromBody] CreditAnalysisApiRequest request, CancellationToken ct)
|
| 81 |
+
{
|
| 82 |
+
var analysis = await _aiService.AnalyzeCreditAsync(new CreditAnalysisRequest
|
| 83 |
+
{
|
| 84 |
+
Data = request.ApplicationData,
|
| 85 |
+
Decision = request.Decision,
|
| 86 |
+
RiskScore = request.RiskScore,
|
| 87 |
+
RiskCategory = request.RiskCategory,
|
| 88 |
+
ProductCode = request.ProductCode,
|
| 89 |
+
Deviations = request.Deviations.Select(d => new ExecutionDeviation
|
| 90 |
+
{
|
| 91 |
+
DeviationCode = d.Code,
|
| 92 |
+
DeviationName = d.Name,
|
| 93 |
+
Severity = Enum.Parse<Severity>(d.Severity, true),
|
| 94 |
+
Reason = d.Reason
|
| 95 |
+
}).ToList()
|
| 96 |
+
}, ct);
|
| 97 |
+
|
| 98 |
+
return Ok(analysis);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/// <summary>
|
| 102 |
+
/// Analyze deviations and provide mitigation recommendations.
|
| 103 |
+
/// </summary>
|
| 104 |
+
[HttpPost("analyze-deviations")]
|
| 105 |
+
[Authorize(Policy = "AiAnalysis")]
|
| 106 |
+
[ProducesResponseType(typeof(DeviationAnalysis), 200)]
|
| 107 |
+
public async Task<IActionResult> AnalyzeDeviations([FromBody] DeviationAnalysisApiRequest request, CancellationToken ct)
|
| 108 |
+
{
|
| 109 |
+
var analysis = await _aiService.AnalyzeDeviationsAsync(new DeviationAnalysisRequest
|
| 110 |
+
{
|
| 111 |
+
ApplicationData = request.ApplicationData,
|
| 112 |
+
Deviations = request.Deviations.Select(d => new ExecutionDeviation
|
| 113 |
+
{
|
| 114 |
+
DeviationCode = d.Code,
|
| 115 |
+
DeviationName = d.Name,
|
| 116 |
+
Severity = Enum.Parse<Severity>(d.Severity, true),
|
| 117 |
+
Reason = d.Reason,
|
| 118 |
+
ActualValue = d.ActualValue,
|
| 119 |
+
ExpectedValue = d.ExpectedValue,
|
| 120 |
+
TenantId = _tenantAccessor.TenantId
|
| 121 |
+
}).ToList()
|
| 122 |
+
}, ct);
|
| 123 |
+
|
| 124 |
+
return Ok(analysis);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/// <summary>
|
| 128 |
+
/// Accept a generated rule and save to rule engine.
|
| 129 |
+
/// </summary>
|
| 130 |
+
[HttpPost("generated-rules/{generationId:guid}/accept")]
|
| 131 |
+
[Authorize(Policy = "RuleWrite")]
|
| 132 |
+
public async Task<IActionResult> AcceptGeneratedRule(Guid generationId, [FromBody] AcceptRuleRequest request, CancellationToken ct)
|
| 133 |
+
{
|
| 134 |
+
var saved = await _generatedRuleRepo.AcceptRuleAsync(generationId, _tenantAccessor.TenantId, request.CategoryId, _tenantAccessor.UserId, ct);
|
| 135 |
+
if (!saved) return NotFound();
|
| 136 |
+
|
| 137 |
+
return Ok(new { message = "Rule saved to rule engine successfully", ruleId = saved });
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/// <summary>
|
| 141 |
+
/// Get AI generation history.
|
| 142 |
+
/// </summary>
|
| 143 |
+
[HttpGet("generated-rules")]
|
| 144 |
+
[ProducesResponseType(typeof(List<AiGeneratedRuleRecord>), 200)]
|
| 145 |
+
public async Task<IActionResult> GetGenerationHistory([FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
|
| 146 |
+
{
|
| 147 |
+
var history = await _generatedRuleRepo.GetHistoryAsync(_tenantAccessor.TenantId, page, pageSize, ct);
|
| 148 |
+
return Ok(history);
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// DTOs
|
| 153 |
+
public record GenerateRuleRequest
|
| 154 |
+
{
|
| 155 |
+
public string Prompt { get; init; } = default!;
|
| 156 |
+
public string? ProductType { get; init; }
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
public record GeneratedRuleResponse
|
| 160 |
+
{
|
| 161 |
+
public Guid GenerationId { get; init; }
|
| 162 |
+
public string UserPrompt { get; init; } = default!;
|
| 163 |
+
public RuleDefinition RuleDefinition { get; init; } = default!;
|
| 164 |
+
public bool CanEdit { get; init; }
|
| 165 |
+
public string Message { get; init; } = default!;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
public record CreditAnalysisApiRequest
|
| 169 |
+
{
|
| 170 |
+
public Dictionary<string, object> ApplicationData { get; init; } = new();
|
| 171 |
+
public string Decision { get; init; } = default!;
|
| 172 |
+
public decimal RiskScore { get; init; }
|
| 173 |
+
public string RiskCategory { get; init; } = default!;
|
| 174 |
+
public string? ProductCode { get; init; }
|
| 175 |
+
public List<DeviationDto> Deviations { get; init; } = new();
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
public record DeviationAnalysisApiRequest
|
| 179 |
+
{
|
| 180 |
+
public Dictionary<string, object> ApplicationData { get; init; } = new();
|
| 181 |
+
public List<DeviationDto> Deviations { get; init; } = new();
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
public record DeviationDto
|
| 185 |
+
{
|
| 186 |
+
public string Code { get; init; } = default!;
|
| 187 |
+
public string Name { get; init; } = default!;
|
| 188 |
+
public string Severity { get; init; } = default!;
|
| 189 |
+
public string Reason { get; init; } = default!;
|
| 190 |
+
public string? ActualValue { get; init; }
|
| 191 |
+
public string? ExpectedValue { get; init; }
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
public record AcceptRuleRequest
|
| 195 |
+
{
|
| 196 |
+
public Guid? CategoryId { get; init; }
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
public class AiGeneratedRuleRecord
|
| 200 |
+
{
|
| 201 |
+
public Guid Id { get; set; }
|
| 202 |
+
public Guid TenantId { get; set; }
|
| 203 |
+
public string UserPrompt { get; set; } = default!;
|
| 204 |
+
public RuleDefinition GeneratedRule { get; set; } = default!;
|
| 205 |
+
public Guid? RuleId { get; set; }
|
| 206 |
+
public bool? IsAccepted { get; set; }
|
| 207 |
+
public Guid CreatedBy { get; set; }
|
| 208 |
+
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
public interface IAiRuleGeneratorRepository
|
| 212 |
+
{
|
| 213 |
+
Task<Guid> SaveGeneratedRuleAsync(AiGeneratedRuleRecord record, CancellationToken ct = default);
|
| 214 |
+
Task<bool> AcceptRuleAsync(Guid generationId, Guid tenantId, Guid? categoryId, Guid userId, CancellationToken ct = default);
|
| 215 |
+
Task<List<AiGeneratedRuleRecord>> GetHistoryAsync(Guid tenantId, int page, int pageSize, CancellationToken ct = default);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
public interface IFieldCatalogRepository
|
| 219 |
+
{
|
| 220 |
+
Task<List<string>> GetFieldPathsAsync(Guid tenantId, CancellationToken ct = default);
|
| 221 |
+
Task<List<FieldCatalogDto>> GetCatalogAsync(Guid tenantId, string? category = null, CancellationToken ct = default);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
public record FieldCatalogDto
|
| 225 |
+
{
|
| 226 |
+
public string FieldPath { get; init; } = default!;
|
| 227 |
+
public string DisplayName { get; init; } = default!;
|
| 228 |
+
public string DataType { get; init; } = default!;
|
| 229 |
+
public string? Category { get; init; }
|
| 230 |
+
public string? Description { get; init; }
|
| 231 |
+
}
|
AppShell.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react'
|
| 4 |
+
import Link from 'next/link'
|
| 5 |
+
import { usePathname } from 'next/navigation'
|
| 6 |
+
|
| 7 |
+
const NAV_ITEMS = [
|
| 8 |
+
{ href: '/', label: 'Dashboard', icon: '📊' },
|
| 9 |
+
{ href: '/rules', label: 'Rule Designer', icon: '⚙️' },
|
| 10 |
+
{ href: '/rule-sets', label: 'Rule Sets', icon: '📦' },
|
| 11 |
+
{ href: '/sandbox', label: 'Sandbox', icon: '🧪' },
|
| 12 |
+
{ href: '/decisions', label: 'Decisions', icon: '⚖️' },
|
| 13 |
+
{ href: '/deviations', label: 'Deviations', icon: '⚠️' },
|
| 14 |
+
{ href: '/ai-rules', label: 'AI Rule Creator', icon: '🤖' },
|
| 15 |
+
{ href: '/marketplace', label: 'Marketplace', icon: '🛒' },
|
| 16 |
+
{ href: '/clients', label: 'Clients', icon: '🏢' },
|
| 17 |
+
{ href: '/reports', label: 'Reports', icon: '📄' },
|
| 18 |
+
{ href: '/audit', label: 'Audit Trail', icon: '🔍' },
|
| 19 |
+
{ href: '/settings', label: 'Settings', icon: '🔧' },
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
export function AppShell({ children }: { children: React.ReactNode }) {
|
| 23 |
+
const pathname = usePathname()
|
| 24 |
+
const [collapsed, setCollapsed] = useState(false)
|
| 25 |
+
|
| 26 |
+
// Don't show shell on login page
|
| 27 |
+
if (pathname === '/login') return <>{children}</>
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<div className="flex h-screen bg-gray-50 overflow-hidden">
|
| 31 |
+
{/* Sidebar */}
|
| 32 |
+
<aside className={`${collapsed ? 'w-16' : 'w-56'} flex flex-col bg-gray-900 text-white transition-all duration-200 flex-shrink-0`}>
|
| 33 |
+
{/* Logo */}
|
| 34 |
+
<div className="flex items-center gap-3 px-4 py-5 border-b border-gray-700">
|
| 35 |
+
<div className="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center text-sm font-black">O</div>
|
| 36 |
+
{!collapsed && (
|
| 37 |
+
<div>
|
| 38 |
+
<div className="text-sm font-bold leading-tight">OPTIM AI</div>
|
| 39 |
+
<div className="text-xs text-gray-400">BRE Engine</div>
|
| 40 |
+
</div>
|
| 41 |
+
)}
|
| 42 |
+
<button
|
| 43 |
+
onClick={() => setCollapsed(!collapsed)}
|
| 44 |
+
className="ml-auto text-gray-400 hover:text-white text-xs"
|
| 45 |
+
>
|
| 46 |
+
{collapsed ? '▶' : '◀'}
|
| 47 |
+
</button>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
{/* Nav Items */}
|
| 51 |
+
<nav className="flex-1 py-4 overflow-y-auto">
|
| 52 |
+
{NAV_ITEMS.map(({ href, label, icon }) => {
|
| 53 |
+
const isActive = pathname === href || (href !== '/' && pathname.startsWith(href))
|
| 54 |
+
return (
|
| 55 |
+
<Link
|
| 56 |
+
key={href}
|
| 57 |
+
href={href}
|
| 58 |
+
className={`flex items-center gap-3 px-4 py-2.5 mx-2 rounded-lg transition-colors text-sm ${
|
| 59 |
+
isActive
|
| 60 |
+
? 'bg-blue-600 text-white font-medium'
|
| 61 |
+
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
| 62 |
+
}`}
|
| 63 |
+
title={collapsed ? label : undefined}
|
| 64 |
+
>
|
| 65 |
+
<span className="text-base flex-shrink-0">{icon}</span>
|
| 66 |
+
{!collapsed && <span>{label}</span>}
|
| 67 |
+
</Link>
|
| 68 |
+
)
|
| 69 |
+
})}
|
| 70 |
+
</nav>
|
| 71 |
+
|
| 72 |
+
{/* Footer */}
|
| 73 |
+
<div className="border-t border-gray-700 px-4 py-3">
|
| 74 |
+
{!collapsed && (
|
| 75 |
+
<div className="flex items-center gap-2">
|
| 76 |
+
<div className="w-7 h-7 rounded-full bg-blue-500 flex items-center justify-center text-xs font-bold">A</div>
|
| 77 |
+
<div>
|
| 78 |
+
<div className="text-xs font-medium text-white">Admin User</div>
|
| 79 |
+
<div className="text-xs text-gray-400">Super Admin</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
)}
|
| 83 |
+
</div>
|
| 84 |
+
</aside>
|
| 85 |
+
|
| 86 |
+
{/* Main Content */}
|
| 87 |
+
<main className="flex-1 overflow-auto">{children}</main>
|
| 88 |
+
</div>
|
| 89 |
+
)
|
| 90 |
+
}
|
AuthController.cs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.AspNetCore.Mvc;
|
| 2 |
+
using Microsoft.IdentityModel.Tokens;
|
| 3 |
+
using OptimAI.BRE.RuleEngine.Infrastructure;
|
| 4 |
+
using OptimAI.BRE.Shared.Domain;
|
| 5 |
+
using System.IdentityModel.Tokens.Jwt;
|
| 6 |
+
using System.Security.Claims;
|
| 7 |
+
using System.Security.Cryptography;
|
| 8 |
+
using System.Text;
|
| 9 |
+
using Microsoft.EntityFrameworkCore;
|
| 10 |
+
using Microsoft.Extensions.Options;
|
| 11 |
+
|
| 12 |
+
namespace OptimAI.BRE.IdentityService.Api;
|
| 13 |
+
|
| 14 |
+
[ApiController]
|
| 15 |
+
[Route("api/v1/auth")]
|
| 16 |
+
public sealed class AuthController : ControllerBase
|
| 17 |
+
{
|
| 18 |
+
private readonly BREDbContext _db;
|
| 19 |
+
private readonly JwtOptions _jwt;
|
| 20 |
+
private readonly ILogger<AuthController> _logger;
|
| 21 |
+
|
| 22 |
+
public AuthController(BREDbContext db, IOptions<JwtOptions> jwt, ILogger<AuthController> logger)
|
| 23 |
+
{
|
| 24 |
+
_db = db;
|
| 25 |
+
_jwt = jwt.Value;
|
| 26 |
+
_logger = logger;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
[HttpPost("login")]
|
| 30 |
+
[ProducesResponseType(typeof(LoginResponse), 200)]
|
| 31 |
+
[ProducesResponseType(typeof(ProblemDetails), 401)]
|
| 32 |
+
public async Task<IActionResult> Login([FromBody] LoginRequest request, CancellationToken ct)
|
| 33 |
+
{
|
| 34 |
+
var user = await _db.Users
|
| 35 |
+
.Include(u => u.UserRoles)
|
| 36 |
+
.ThenInclude(ur => ur.Role)
|
| 37 |
+
.ThenInclude(r => r.RolePermissions)
|
| 38 |
+
.ThenInclude(rp => rp.Permission)
|
| 39 |
+
.FirstOrDefaultAsync(u =>
|
| 40 |
+
u.Email == request.Email.ToLower() &&
|
| 41 |
+
u.IsActive, ct);
|
| 42 |
+
|
| 43 |
+
if (user == null || !VerifyPassword(request.Password, user.PasswordHash))
|
| 44 |
+
{
|
| 45 |
+
if (user != null)
|
| 46 |
+
{
|
| 47 |
+
user.FailedLoginCount++;
|
| 48 |
+
if (user.FailedLoginCount >= 5)
|
| 49 |
+
user.LockedUntil = DateTime.UtcNow.AddMinutes(30);
|
| 50 |
+
await _db.SaveChangesAsync(ct);
|
| 51 |
+
}
|
| 52 |
+
return Unauthorized(new { error = "Invalid email or password" });
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
if (user.LockedUntil.HasValue && user.LockedUntil > DateTime.UtcNow)
|
| 56 |
+
return Unauthorized(new { error = "Account locked. Try again later." });
|
| 57 |
+
|
| 58 |
+
user.FailedLoginCount = 0;
|
| 59 |
+
user.LastLoginAt = DateTime.UtcNow;
|
| 60 |
+
user.LastLoginIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
| 61 |
+
await _db.SaveChangesAsync(ct);
|
| 62 |
+
|
| 63 |
+
var permissions = user.UserRoles
|
| 64 |
+
.SelectMany(ur => ur.Role.RolePermissions)
|
| 65 |
+
.Select(rp => rp.Permission.PermissionCode)
|
| 66 |
+
.Distinct()
|
| 67 |
+
.ToList();
|
| 68 |
+
|
| 69 |
+
var accessToken = GenerateAccessToken(user, permissions);
|
| 70 |
+
var refreshToken = GenerateRefreshToken();
|
| 71 |
+
|
| 72 |
+
_db.RefreshTokens.Add(new RefreshToken
|
| 73 |
+
{
|
| 74 |
+
UserId = user.Id,
|
| 75 |
+
TokenHash = HashToken(refreshToken),
|
| 76 |
+
ExpiresAt = DateTime.UtcNow.AddDays(_jwt.RefreshTokenExpiryDays),
|
| 77 |
+
IpAddress = user.LastLoginIp,
|
| 78 |
+
UserAgent = Request.Headers.UserAgent.ToString()
|
| 79 |
+
});
|
| 80 |
+
await _db.SaveChangesAsync(ct);
|
| 81 |
+
|
| 82 |
+
return Ok(new LoginResponse
|
| 83 |
+
{
|
| 84 |
+
AccessToken = accessToken,
|
| 85 |
+
RefreshToken = refreshToken,
|
| 86 |
+
ExpiresIn = _jwt.AccessTokenExpiryMinutes * 60,
|
| 87 |
+
TokenType = "Bearer",
|
| 88 |
+
User = new UserDto
|
| 89 |
+
{
|
| 90 |
+
Id = user.Id,
|
| 91 |
+
Email = user.Email,
|
| 92 |
+
FullName = user.FullName,
|
| 93 |
+
TenantId = user.TenantId,
|
| 94 |
+
Permissions = permissions,
|
| 95 |
+
Roles = user.UserRoles.Select(ur => ur.Role.RoleCode).ToList()
|
| 96 |
+
}
|
| 97 |
+
});
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
[HttpPost("refresh")]
|
| 101 |
+
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request, CancellationToken ct)
|
| 102 |
+
{
|
| 103 |
+
var hash = HashToken(request.RefreshToken);
|
| 104 |
+
var token = await _db.RefreshTokens
|
| 105 |
+
.Include(t => t.User)
|
| 106 |
+
.FirstOrDefaultAsync(t =>
|
| 107 |
+
t.TokenHash == hash &&
|
| 108 |
+
!t.IsRevoked &&
|
| 109 |
+
t.ExpiresAt > DateTime.UtcNow, ct);
|
| 110 |
+
|
| 111 |
+
if (token == null)
|
| 112 |
+
return Unauthorized(new { error = "Invalid or expired refresh token" });
|
| 113 |
+
|
| 114 |
+
// Rotate refresh token
|
| 115 |
+
token.IsRevoked = true;
|
| 116 |
+
var newRefreshToken = GenerateRefreshToken();
|
| 117 |
+
_db.RefreshTokens.Add(new RefreshToken
|
| 118 |
+
{
|
| 119 |
+
UserId = token.UserId,
|
| 120 |
+
TokenHash = HashToken(newRefreshToken),
|
| 121 |
+
ExpiresAt = DateTime.UtcNow.AddDays(_jwt.RefreshTokenExpiryDays)
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
await _db.SaveChangesAsync(ct);
|
| 125 |
+
|
| 126 |
+
var permissions = await GetUserPermissionsAsync(token.UserId, ct);
|
| 127 |
+
var accessToken = GenerateAccessToken(token.User!, permissions);
|
| 128 |
+
|
| 129 |
+
return Ok(new { accessToken, refreshToken = newRefreshToken });
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
[HttpPost("logout")]
|
| 133 |
+
public async Task<IActionResult> Logout([FromBody] LogoutRequest request, CancellationToken ct)
|
| 134 |
+
{
|
| 135 |
+
var hash = HashToken(request.RefreshToken);
|
| 136 |
+
await _db.RefreshTokens
|
| 137 |
+
.Where(t => t.TokenHash == hash)
|
| 138 |
+
.ExecuteUpdateAsync(s => s.SetProperty(t => t.IsRevoked, true), ct);
|
| 139 |
+
return Ok(new { message = "Logged out successfully" });
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
private string GenerateAccessToken(User user, List<string> permissions)
|
| 143 |
+
{
|
| 144 |
+
var claims = new List<Claim>
|
| 145 |
+
{
|
| 146 |
+
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
| 147 |
+
new(JwtRegisteredClaimNames.Email, user.Email),
|
| 148 |
+
new(JwtRegisteredClaimNames.Name, user.FullName),
|
| 149 |
+
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
| 150 |
+
new("tenant_id", user.TenantId.ToString()),
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
foreach (var p in permissions)
|
| 154 |
+
claims.Add(new Claim("permission", p));
|
| 155 |
+
|
| 156 |
+
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SecretKey));
|
| 157 |
+
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
| 158 |
+
|
| 159 |
+
var token = new JwtSecurityToken(
|
| 160 |
+
issuer: _jwt.Issuer,
|
| 161 |
+
audience: _jwt.Audience,
|
| 162 |
+
claims: claims,
|
| 163 |
+
expires: DateTime.UtcNow.AddMinutes(_jwt.AccessTokenExpiryMinutes),
|
| 164 |
+
signingCredentials: creds);
|
| 165 |
+
|
| 166 |
+
return new JwtSecurityTokenHandler().WriteToken(token);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
private static string GenerateRefreshToken()
|
| 170 |
+
{
|
| 171 |
+
var bytes = new byte[64];
|
| 172 |
+
using var rng = RandomNumberGenerator.Create();
|
| 173 |
+
rng.GetBytes(bytes);
|
| 174 |
+
return Convert.ToBase64String(bytes);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
private static string HashToken(string token)
|
| 178 |
+
{
|
| 179 |
+
using var sha = SHA256.Create();
|
| 180 |
+
return Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(token))).ToLower();
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
private static bool VerifyPassword(string password, string hash)
|
| 184 |
+
=> BCrypt.Net.BCrypt.Verify(password, hash);
|
| 185 |
+
|
| 186 |
+
private async Task<List<string>> GetUserPermissionsAsync(Guid userId, CancellationToken ct)
|
| 187 |
+
{
|
| 188 |
+
return await _db.UserRoles
|
| 189 |
+
.Where(ur => ur.UserId == userId)
|
| 190 |
+
.SelectMany(ur => ur.Role.RolePermissions)
|
| 191 |
+
.Select(rp => rp.Permission.PermissionCode)
|
| 192 |
+
.Distinct()
|
| 193 |
+
.ToListAsync(ct);
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// Models
|
| 198 |
+
public class JwtOptions
|
| 199 |
+
{
|
| 200 |
+
public string SecretKey { get; set; } = default!;
|
| 201 |
+
public string Issuer { get; set; } = default!;
|
| 202 |
+
public string Audience { get; set; } = default!;
|
| 203 |
+
public int AccessTokenExpiryMinutes { get; set; } = 60;
|
| 204 |
+
public int RefreshTokenExpiryDays { get; set; } = 30;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
public record LoginRequest
|
| 208 |
+
{
|
| 209 |
+
public string Email { get; init; } = default!;
|
| 210 |
+
public string Password { get; init; } = default!;
|
| 211 |
+
public bool RememberMe { get; init; }
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
public record LoginResponse
|
| 215 |
+
{
|
| 216 |
+
public string AccessToken { get; init; } = default!;
|
| 217 |
+
public string RefreshToken { get; init; } = default!;
|
| 218 |
+
public int ExpiresIn { get; init; }
|
| 219 |
+
public string TokenType { get; init; } = "Bearer";
|
| 220 |
+
public UserDto User { get; init; } = default!;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
public record UserDto
|
| 224 |
+
{
|
| 225 |
+
public Guid Id { get; init; }
|
| 226 |
+
public string Email { get; init; } = default!;
|
| 227 |
+
public string FullName { get; init; } = default!;
|
| 228 |
+
public Guid TenantId { get; init; }
|
| 229 |
+
public List<string> Permissions { get; init; } = new();
|
| 230 |
+
public List<string> Roles { get; init; } = new();
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
public record RefreshRequest { public string RefreshToken { get; init; } = default!; }
|
| 234 |
+
public record LogoutRequest { public string RefreshToken { get; init; } = default!; }
|
BREController.cs
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.AspNetCore.Authorization;
|
| 2 |
+
using Microsoft.AspNetCore.Mvc;
|
| 3 |
+
using Microsoft.AspNetCore.RateLimiting;
|
| 4 |
+
using OptimAI.BRE.RuleEngine.Application;
|
| 5 |
+
using OptimAI.BRE.RuleEngine.Domain;
|
| 6 |
+
using OptimAI.BRE.Shared.Domain;
|
| 7 |
+
using System.ComponentModel.DataAnnotations;
|
| 8 |
+
|
| 9 |
+
namespace OptimAI.BRE.RuleEngine.Api;
|
| 10 |
+
|
| 11 |
+
[ApiController]
|
| 12 |
+
[Route("api/v1")]
|
| 13 |
+
[Authorize]
|
| 14 |
+
public sealed class BREController : ControllerBase
|
| 15 |
+
{
|
| 16 |
+
private readonly IRuleExecutionService _executionService;
|
| 17 |
+
private readonly IExecutionRequestRepository _requestRepo;
|
| 18 |
+
private readonly IExecutionResultRepository _resultRepo;
|
| 19 |
+
private readonly ISandboxService _sandboxService;
|
| 20 |
+
private readonly IDecisionReportService _reportService;
|
| 21 |
+
private readonly ITenantContextAccessor _tenantAccessor;
|
| 22 |
+
private readonly ILogger<BREController> _logger;
|
| 23 |
+
|
| 24 |
+
public BREController(
|
| 25 |
+
IRuleExecutionService executionService,
|
| 26 |
+
IExecutionRequestRepository requestRepo,
|
| 27 |
+
IExecutionResultRepository resultRepo,
|
| 28 |
+
ISandboxService sandboxService,
|
| 29 |
+
IDecisionReportService reportService,
|
| 30 |
+
ITenantContextAccessor tenantAccessor,
|
| 31 |
+
ILogger<BREController> logger)
|
| 32 |
+
{
|
| 33 |
+
_executionService = executionService;
|
| 34 |
+
_requestRepo = requestRepo;
|
| 35 |
+
_resultRepo = resultRepo;
|
| 36 |
+
_sandboxService = sandboxService;
|
| 37 |
+
_reportService = reportService;
|
| 38 |
+
_tenantAccessor = tenantAccessor;
|
| 39 |
+
_logger = logger;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/// <summary>
|
| 43 |
+
/// Execute BRE rules against provided JSON payload. Core decision API.
|
| 44 |
+
/// </summary>
|
| 45 |
+
[HttpPost("execute-bre")]
|
| 46 |
+
[EnableRateLimiting("bre-execution")]
|
| 47 |
+
[ProducesResponseType(typeof(BREDecisionResponse), 200)]
|
| 48 |
+
[ProducesResponseType(typeof(ProblemDetails), 400)]
|
| 49 |
+
[ProducesResponseType(typeof(ProblemDetails), 429)]
|
| 50 |
+
public async Task<IActionResult> ExecuteBre(
|
| 51 |
+
[FromBody] BREExecutionRequest request,
|
| 52 |
+
CancellationToken ct)
|
| 53 |
+
{
|
| 54 |
+
var tenantId = _tenantAccessor.TenantId;
|
| 55 |
+
|
| 56 |
+
var executionRequest = new ExecutionRequest
|
| 57 |
+
{
|
| 58 |
+
TenantId = tenantId,
|
| 59 |
+
CorrelationId = request.CorrelationId ?? Guid.NewGuid().ToString(),
|
| 60 |
+
ApplicationId = request.ApplicationId,
|
| 61 |
+
ProductCode = request.ProductCode,
|
| 62 |
+
BranchCode = request.BranchCode,
|
| 63 |
+
StageCode = request.StageCode,
|
| 64 |
+
RuleSetId = request.RuleSetId,
|
| 65 |
+
InputPayload = request.Data,
|
| 66 |
+
SourceSystem = request.SourceSystem,
|
| 67 |
+
Status = ExecutionStatus.Processing,
|
| 68 |
+
ProcessingStartedAt = DateTime.UtcNow
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
executionRequest = await _requestRepo.CreateAsync(executionRequest, ct);
|
| 72 |
+
|
| 73 |
+
var context = new ExecutionContext
|
| 74 |
+
{
|
| 75 |
+
TenantId = tenantId,
|
| 76 |
+
CorrelationId = executionRequest.CorrelationId,
|
| 77 |
+
ApplicationId = request.ApplicationId,
|
| 78 |
+
ProductCode = request.ProductCode,
|
| 79 |
+
BranchCode = request.BranchCode,
|
| 80 |
+
StageCode = request.StageCode,
|
| 81 |
+
RuleSetId = request.RuleSetId,
|
| 82 |
+
Data = new DynamicDataContext(request.Data),
|
| 83 |
+
Options = new ExecutionOptions
|
| 84 |
+
{
|
| 85 |
+
EnableAiAnalysis = request.EnableAiAnalysis ?? true,
|
| 86 |
+
EnableRiskScoring = true,
|
| 87 |
+
StopOnFirstReject = false,
|
| 88 |
+
TimeoutMs = 5000
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
var result = await _executionService.ExecuteAsync(context, ct);
|
| 93 |
+
result.RequestId = executionRequest.Id;
|
| 94 |
+
|
| 95 |
+
await _resultRepo.SaveAsync(result, ct);
|
| 96 |
+
await _requestRepo.MarkCompletedAsync(executionRequest.Id, ct);
|
| 97 |
+
|
| 98 |
+
var report = await _reportService.GenerateAsync(executionRequest.Id, result, ct);
|
| 99 |
+
|
| 100 |
+
return Ok(new BREDecisionResponse
|
| 101 |
+
{
|
| 102 |
+
RequestId = executionRequest.Id,
|
| 103 |
+
CorrelationId = executionRequest.CorrelationId!,
|
| 104 |
+
ApplicationId = request.ApplicationId,
|
| 105 |
+
Decision = result.FinalDecision.ToString(),
|
| 106 |
+
TrafficLight = result.TrafficLight?.ToString() ?? "AMBER",
|
| 107 |
+
RiskScore = result.RiskScore ?? 0,
|
| 108 |
+
RiskCategory = result.RiskCategory?.ToString() ?? "MEDIUM",
|
| 109 |
+
TotalRulesEvaluated = result.TotalRulesEvaluated,
|
| 110 |
+
RulesPassed = result.RulesPassed,
|
| 111 |
+
RulesFailed = result.RulesFailed,
|
| 112 |
+
DeviationsCount = result.DeviationsCount,
|
| 113 |
+
ExecutionMs = result.ExecutionMs ?? 0,
|
| 114 |
+
RuleResults = result.RuleResults.Select(r => new RuleResultDto
|
| 115 |
+
{
|
| 116 |
+
RuleCode = r.RuleCode,
|
| 117 |
+
RuleName = r.RuleName,
|
| 118 |
+
IsMatched = r.IsMatched,
|
| 119 |
+
ActionsExecuted = r.ActionsExecuted,
|
| 120 |
+
ExecutionMs = r.ExecutionMs ?? 0
|
| 121 |
+
}).ToList(),
|
| 122 |
+
AiSummary = result.AiSummary,
|
| 123 |
+
AiAnalysis = result.AiAnalysis != null ? new AiAnalysisDto
|
| 124 |
+
{
|
| 125 |
+
RiskSummary = result.AiAnalysis.RiskSummary,
|
| 126 |
+
CreditSummary = result.AiAnalysis.CreditSummary,
|
| 127 |
+
Strengths = result.AiAnalysis.Strengths,
|
| 128 |
+
Weaknesses = result.AiAnalysis.Weaknesses,
|
| 129 |
+
ApprovalRecommendation = result.AiAnalysis.ApprovalRecommendation,
|
| 130 |
+
RejectionReasons = result.AiAnalysis.RejectionReasons,
|
| 131 |
+
AdditionalDocuments = result.AiAnalysis.AdditionalDocuments,
|
| 132 |
+
UnderwritingNotes = result.AiAnalysis.UnderwritingNotes,
|
| 133 |
+
ConfidenceScore = result.AiAnalysis.ConfidenceScore
|
| 134 |
+
} : null,
|
| 135 |
+
ReportId = report?.Id,
|
| 136 |
+
GeneratedAt = DateTime.UtcNow
|
| 137 |
+
});
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/// <summary>
|
| 141 |
+
/// Validate a rule definition before saving.
|
| 142 |
+
/// </summary>
|
| 143 |
+
[HttpPost("validate-rule")]
|
| 144 |
+
[Authorize(Policy = "RuleWrite")]
|
| 145 |
+
[ProducesResponseType(typeof(RuleValidationResponse), 200)]
|
| 146 |
+
public async Task<IActionResult> ValidateRule([FromBody] RuleValidationRequest request)
|
| 147 |
+
{
|
| 148 |
+
var errors = new List<string>();
|
| 149 |
+
var warnings = new List<string>();
|
| 150 |
+
|
| 151 |
+
// Validate rule definition structure
|
| 152 |
+
if (request.RuleDefinition == null)
|
| 153 |
+
{
|
| 154 |
+
errors.Add("Rule definition is required");
|
| 155 |
+
return Ok(new RuleValidationResponse { IsValid = false, Errors = errors });
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
if (request.RuleDefinition.Conditions == null || !request.RuleDefinition.Conditions.Rules.Any())
|
| 159 |
+
errors.Add("At least one condition is required");
|
| 160 |
+
|
| 161 |
+
if (!request.RuleDefinition.Actions.Any())
|
| 162 |
+
warnings.Add("Rule has no actions defined");
|
| 163 |
+
|
| 164 |
+
// Check referenced fields exist in catalog
|
| 165 |
+
var allFields = ExtractFieldPaths(request.RuleDefinition.Conditions);
|
| 166 |
+
foreach (var field in allFields)
|
| 167 |
+
{
|
| 168 |
+
if (!IsValidFieldPath(field))
|
| 169 |
+
warnings.Add($"Field '{field}' not found in field catalog");
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// Test with sample data if provided
|
| 173 |
+
if (request.SampleData != null)
|
| 174 |
+
{
|
| 175 |
+
var context = new ExecutionContext
|
| 176 |
+
{
|
| 177 |
+
TenantId = _tenantAccessor.TenantId,
|
| 178 |
+
Data = new DynamicDataContext(request.SampleData),
|
| 179 |
+
Options = new ExecutionOptions { EnableAiAnalysis = false }
|
| 180 |
+
};
|
| 181 |
+
|
| 182 |
+
// Quick dry-run against sample data using the condition evaluator
|
| 183 |
+
// This validates conditions can be parsed and executed
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
return Ok(new RuleValidationResponse
|
| 187 |
+
{
|
| 188 |
+
IsValid = !errors.Any(),
|
| 189 |
+
Errors = errors,
|
| 190 |
+
Warnings = warnings,
|
| 191 |
+
ExtractedFields = allFields
|
| 192 |
+
});
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/// <summary>
|
| 196 |
+
/// Simulate BRE execution without persisting results.
|
| 197 |
+
/// </summary>
|
| 198 |
+
[HttpPost("simulate-decision")]
|
| 199 |
+
[Authorize(Policy = "SandboxAccess")]
|
| 200 |
+
[ProducesResponseType(typeof(SimulationResponse), 200)]
|
| 201 |
+
public async Task<IActionResult> SimulateDecision([FromBody] SimulationRequest request, CancellationToken ct)
|
| 202 |
+
{
|
| 203 |
+
var result = await _sandboxService.SimulateAsync(new SandboxRequest
|
| 204 |
+
{
|
| 205 |
+
TenantId = _tenantAccessor.TenantId,
|
| 206 |
+
RuleSetId = request.RuleSetId,
|
| 207 |
+
TestData = request.Data,
|
| 208 |
+
RuleIds = request.RuleIds,
|
| 209 |
+
CreatedBy = _tenantAccessor.UserId
|
| 210 |
+
}, ct);
|
| 211 |
+
|
| 212 |
+
return Ok(result);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/// <summary>
|
| 216 |
+
/// Get execution result by request ID.
|
| 217 |
+
/// </summary>
|
| 218 |
+
[HttpGet("decisions/{requestId:guid}")]
|
| 219 |
+
[ProducesResponseType(typeof(BREDecisionResponse), 200)]
|
| 220 |
+
[ProducesResponseType(404)]
|
| 221 |
+
public async Task<IActionResult> GetDecision(Guid requestId, CancellationToken ct)
|
| 222 |
+
{
|
| 223 |
+
var result = await _resultRepo.GetByRequestIdAsync(requestId, _tenantAccessor.TenantId, ct);
|
| 224 |
+
if (result == null) return NotFound();
|
| 225 |
+
return Ok(result);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/// <summary>
|
| 229 |
+
/// Get decision history with pagination.
|
| 230 |
+
/// </summary>
|
| 231 |
+
[HttpGet("decisions")]
|
| 232 |
+
[ProducesResponseType(typeof(PagedResponse<DecisionSummaryDto>), 200)]
|
| 233 |
+
public async Task<IActionResult> GetDecisionHistory(
|
| 234 |
+
[FromQuery] int page = 1,
|
| 235 |
+
[FromQuery] int pageSize = 20,
|
| 236 |
+
[FromQuery] string? decision = null,
|
| 237 |
+
[FromQuery] string? applicationId = null,
|
| 238 |
+
[FromQuery] DateTime? fromDate = null,
|
| 239 |
+
[FromQuery] DateTime? toDate = null,
|
| 240 |
+
CancellationToken ct = default)
|
| 241 |
+
{
|
| 242 |
+
var result = await _resultRepo.GetHistoryAsync(new DecisionHistoryQuery
|
| 243 |
+
{
|
| 244 |
+
TenantId = _tenantAccessor.TenantId,
|
| 245 |
+
Page = page,
|
| 246 |
+
PageSize = Math.Min(pageSize, 100),
|
| 247 |
+
Decision = decision,
|
| 248 |
+
ApplicationId = applicationId,
|
| 249 |
+
FromDate = fromDate,
|
| 250 |
+
ToDate = toDate
|
| 251 |
+
}, ct);
|
| 252 |
+
|
| 253 |
+
return Ok(result);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
private static List<string> ExtractFieldPaths(ConditionGroup? group)
|
| 257 |
+
{
|
| 258 |
+
if (group == null) return new();
|
| 259 |
+
var fields = new List<string>();
|
| 260 |
+
foreach (var node in group.Rules)
|
| 261 |
+
{
|
| 262 |
+
if (node.IsGroup && node.Group != null)
|
| 263 |
+
fields.AddRange(ExtractFieldPaths(node.Group));
|
| 264 |
+
else if (node.Field != null)
|
| 265 |
+
fields.Add(node.Field);
|
| 266 |
+
}
|
| 267 |
+
return fields.Distinct().ToList();
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
private static bool IsValidFieldPath(string field) =>
|
| 271 |
+
!string.IsNullOrWhiteSpace(field) && field.Contains('.');
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// ============================================================
|
| 275 |
+
// REQUEST / RESPONSE DTOs
|
| 276 |
+
// ============================================================
|
| 277 |
+
|
| 278 |
+
public record BREExecutionRequest
|
| 279 |
+
{
|
| 280 |
+
[Required]
|
| 281 |
+
public Dictionary<string, object> Data { get; init; } = new();
|
| 282 |
+
public string? CorrelationId { get; init; }
|
| 283 |
+
public string? ApplicationId { get; init; }
|
| 284 |
+
public string? ProductCode { get; init; }
|
| 285 |
+
public string? BranchCode { get; init; }
|
| 286 |
+
public string? StageCode { get; init; }
|
| 287 |
+
public Guid? RuleSetId { get; init; }
|
| 288 |
+
public string? SourceSystem { get; init; }
|
| 289 |
+
public bool? EnableAiAnalysis { get; init; }
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
public record BREDecisionResponse
|
| 293 |
+
{
|
| 294 |
+
public Guid RequestId { get; init; }
|
| 295 |
+
public string CorrelationId { get; init; } = default!;
|
| 296 |
+
public string? ApplicationId { get; init; }
|
| 297 |
+
public string Decision { get; init; } = default!;
|
| 298 |
+
public string TrafficLight { get; init; } = default!;
|
| 299 |
+
public decimal RiskScore { get; init; }
|
| 300 |
+
public string RiskCategory { get; init; } = default!;
|
| 301 |
+
public int TotalRulesEvaluated { get; init; }
|
| 302 |
+
public int RulesPassed { get; init; }
|
| 303 |
+
public int RulesFailed { get; init; }
|
| 304 |
+
public int DeviationsCount { get; init; }
|
| 305 |
+
public int ExecutionMs { get; init; }
|
| 306 |
+
public List<RuleResultDto> RuleResults { get; init; } = new();
|
| 307 |
+
public string? AiSummary { get; init; }
|
| 308 |
+
public AiAnalysisDto? AiAnalysis { get; init; }
|
| 309 |
+
public Guid? ReportId { get; init; }
|
| 310 |
+
public DateTime GeneratedAt { get; init; }
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
public record RuleResultDto
|
| 314 |
+
{
|
| 315 |
+
public string RuleCode { get; init; } = default!;
|
| 316 |
+
public string RuleName { get; init; } = default!;
|
| 317 |
+
public bool IsMatched { get; init; }
|
| 318 |
+
public List<string> ActionsExecuted { get; init; } = new();
|
| 319 |
+
public int ExecutionMs { get; init; }
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
public record AiAnalysisDto
|
| 323 |
+
{
|
| 324 |
+
public string RiskSummary { get; init; } = default!;
|
| 325 |
+
public string CreditSummary { get; init; } = default!;
|
| 326 |
+
public List<string> Strengths { get; init; } = new();
|
| 327 |
+
public List<string> Weaknesses { get; init; } = new();
|
| 328 |
+
public string ApprovalRecommendation { get; init; } = default!;
|
| 329 |
+
public List<string> RejectionReasons { get; init; } = new();
|
| 330 |
+
public List<string> AdditionalDocuments { get; init; } = new();
|
| 331 |
+
public string UnderwritingNotes { get; init; } = default!;
|
| 332 |
+
public double ConfidenceScore { get; init; }
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
public record RuleValidationRequest
|
| 336 |
+
{
|
| 337 |
+
public RuleDefinition? RuleDefinition { get; init; }
|
| 338 |
+
public Dictionary<string, object>? SampleData { get; init; }
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
public record RuleValidationResponse
|
| 342 |
+
{
|
| 343 |
+
public bool IsValid { get; init; }
|
| 344 |
+
public List<string> Errors { get; init; } = new();
|
| 345 |
+
public List<string> Warnings { get; init; } = new();
|
| 346 |
+
public List<string> ExtractedFields { get; init; } = new();
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
public record SimulationRequest
|
| 350 |
+
{
|
| 351 |
+
public Dictionary<string, object> Data { get; init; } = new();
|
| 352 |
+
public Guid? RuleSetId { get; init; }
|
| 353 |
+
public List<Guid>? RuleIds { get; init; }
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
public record PagedResponse<T>
|
| 357 |
+
{
|
| 358 |
+
public List<T> Items { get; init; } = new();
|
| 359 |
+
public int TotalCount { get; init; }
|
| 360 |
+
public int Page { get; init; }
|
| 361 |
+
public int PageSize { get; init; }
|
| 362 |
+
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
public record DecisionSummaryDto
|
| 366 |
+
{
|
| 367 |
+
public Guid RequestId { get; init; }
|
| 368 |
+
public string? ApplicationId { get; init; }
|
| 369 |
+
public string Decision { get; init; } = default!;
|
| 370 |
+
public string TrafficLight { get; init; } = default!;
|
| 371 |
+
public decimal RiskScore { get; init; }
|
| 372 |
+
public int DeviationsCount { get; init; }
|
| 373 |
+
public DateTime CreatedAt { get; init; }
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
public class DecisionHistoryQuery
|
| 377 |
+
{
|
| 378 |
+
public Guid TenantId { get; set; }
|
| 379 |
+
public int Page { get; set; } = 1;
|
| 380 |
+
public int PageSize { get; set; } = 20;
|
| 381 |
+
public string? Decision { get; set; }
|
| 382 |
+
public string? ApplicationId { get; set; }
|
| 383 |
+
public DateTime? FromDate { get; set; }
|
| 384 |
+
public DateTime? ToDate { get; set; }
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
public interface IExecutionRequestRepository
|
| 388 |
+
{
|
| 389 |
+
Task<ExecutionRequest> CreateAsync(ExecutionRequest request, CancellationToken ct = default);
|
| 390 |
+
Task MarkCompletedAsync(Guid id, CancellationToken ct = default);
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
public interface IExecutionResultRepository
|
| 394 |
+
{
|
| 395 |
+
Task SaveAsync(ExecutionResult result, CancellationToken ct = default);
|
| 396 |
+
Task<ExecutionResult?> GetByRequestIdAsync(Guid requestId, Guid tenantId, CancellationToken ct = default);
|
| 397 |
+
Task<PagedResponse<DecisionSummaryDto>> GetHistoryAsync(DecisionHistoryQuery query, CancellationToken ct = default);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
public interface ISandboxService
|
| 401 |
+
{
|
| 402 |
+
Task<object> SimulateAsync(SandboxRequest request, CancellationToken ct = default);
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
public record SandboxRequest
|
| 406 |
+
{
|
| 407 |
+
public Guid TenantId { get; init; }
|
| 408 |
+
public Guid? RuleSetId { get; init; }
|
| 409 |
+
public Dictionary<string, object> TestData { get; init; } = new();
|
| 410 |
+
public List<Guid>? RuleIds { get; init; }
|
| 411 |
+
public Guid CreatedBy { get; init; }
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
public interface IDecisionReportService
|
| 415 |
+
{
|
| 416 |
+
Task<DecisionReport?> GenerateAsync(Guid requestId, ExecutionResult result, CancellationToken ct = default);
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
public class DecisionReport
|
| 420 |
+
{
|
| 421 |
+
public Guid Id { get; set; }
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
public interface ITenantContextAccessor
|
| 425 |
+
{
|
| 426 |
+
Guid TenantId { get; }
|
| 427 |
+
Guid UserId { get; }
|
| 428 |
+
string? UserEmail { get; }
|
| 429 |
+
}
|
BREDbContext.cs
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.EntityFrameworkCore;
|
| 2 |
+
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
| 3 |
+
using OptimAI.BRE.Shared.Domain;
|
| 4 |
+
using System.Text.Json;
|
| 5 |
+
|
| 6 |
+
namespace OptimAI.BRE.RuleEngine.Infrastructure;
|
| 7 |
+
|
| 8 |
+
public class BREDbContext : DbContext
|
| 9 |
+
{
|
| 10 |
+
public BREDbContext(DbContextOptions<BREDbContext> options) : base(options) { }
|
| 11 |
+
|
| 12 |
+
public DbSet<Tenant> Tenants => Set<Tenant>();
|
| 13 |
+
public DbSet<TenantConfiguration> TenantConfigurations => Set<TenantConfiguration>();
|
| 14 |
+
public DbSet<User> Users => Set<User>();
|
| 15 |
+
public DbSet<Role> Roles => Set<Role>();
|
| 16 |
+
public DbSet<Permission> Permissions => Set<Permission>();
|
| 17 |
+
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
| 18 |
+
public DbSet<UserRole> UserRoles => Set<UserRole>();
|
| 19 |
+
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
| 20 |
+
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
| 21 |
+
public DbSet<Product> Products => Set<Product>();
|
| 22 |
+
public DbSet<Branch> Branches => Set<Branch>();
|
| 23 |
+
public DbSet<LoanStage> LoanStages => Set<LoanStage>();
|
| 24 |
+
public DbSet<RuleCategory> RuleCategories => Set<RuleCategory>();
|
| 25 |
+
public DbSet<Rule> Rules => Set<Rule>();
|
| 26 |
+
public DbSet<RuleVersion> RuleVersions => Set<RuleVersion>();
|
| 27 |
+
public DbSet<RuleScope> RuleScopes => Set<RuleScope>();
|
| 28 |
+
public DbSet<RuleSet> RuleSets => Set<RuleSet>();
|
| 29 |
+
public DbSet<RuleSetMember> RuleSetMembers => Set<RuleSetMember>();
|
| 30 |
+
public DbSet<FieldCatalogEntry> FieldCatalog => Set<FieldCatalogEntry>();
|
| 31 |
+
public DbSet<ExecutionRequest> ExecutionRequests => Set<ExecutionRequest>();
|
| 32 |
+
public DbSet<ExecutionResult> ExecutionResults => Set<ExecutionResult>();
|
| 33 |
+
public DbSet<RuleExecutionDetail> RuleExecutionDetails => Set<RuleExecutionDetail>();
|
| 34 |
+
public DbSet<DeviationType> DeviationTypes => Set<DeviationType>();
|
| 35 |
+
public DbSet<ExecutionDeviation> ExecutionDeviations => Set<ExecutionDeviation>();
|
| 36 |
+
public DbSet<DecisionReportEntity> DecisionReports => Set<DecisionReportEntity>();
|
| 37 |
+
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
| 38 |
+
public DbSet<RuleApproval> RuleApprovals => Set<RuleApproval>();
|
| 39 |
+
public DbSet<AiGeneratedRuleEntity> AiGeneratedRules => Set<AiGeneratedRuleEntity>();
|
| 40 |
+
|
| 41 |
+
protected override void OnModelCreating(ModelBuilder model)
|
| 42 |
+
{
|
| 43 |
+
base.OnModelCreating(model);
|
| 44 |
+
|
| 45 |
+
// ---- TENANT ----
|
| 46 |
+
model.Entity<Tenant>(e =>
|
| 47 |
+
{
|
| 48 |
+
e.HasKey(t => t.Id);
|
| 49 |
+
e.HasIndex(t => t.TenantCode).IsUnique();
|
| 50 |
+
e.Property(t => t.Settings)
|
| 51 |
+
.HasConversion(
|
| 52 |
+
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
| 53 |
+
v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, (JsonSerializerOptions?)null)!)
|
| 54 |
+
.HasColumnType("jsonb");
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
// ---- USER ----
|
| 58 |
+
model.Entity<User>(e =>
|
| 59 |
+
{
|
| 60 |
+
e.HasKey(u => u.Id);
|
| 61 |
+
e.HasIndex(u => new { u.TenantId, u.Email }).IsUnique();
|
| 62 |
+
e.HasIndex(u => new { u.TenantId, u.Username }).IsUnique();
|
| 63 |
+
e.HasOne(u => u.Tenant).WithMany(t => t.Users).HasForeignKey(u => u.TenantId);
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
// ---- ROLE ----
|
| 67 |
+
model.Entity<Role>(e =>
|
| 68 |
+
{
|
| 69 |
+
e.HasKey(r => r.Id);
|
| 70 |
+
e.HasIndex(r => new { r.TenantId, r.RoleCode }).IsUnique();
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
model.Entity<RolePermission>(e =>
|
| 74 |
+
{
|
| 75 |
+
e.HasKey(rp => new { rp.RoleId, rp.PermissionId });
|
| 76 |
+
e.HasOne(rp => rp.Role).WithMany(r => r.RolePermissions).HasForeignKey(rp => rp.RoleId);
|
| 77 |
+
e.HasOne(rp => rp.Permission).WithMany().HasForeignKey(rp => rp.PermissionId);
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
model.Entity<UserRole>(e =>
|
| 81 |
+
{
|
| 82 |
+
e.HasKey(ur => new { ur.UserId, ur.RoleId });
|
| 83 |
+
e.HasOne(ur => ur.User).WithMany(u => u.UserRoles).HasForeignKey(ur => ur.UserId);
|
| 84 |
+
e.HasOne(ur => ur.Role).WithMany(r => r.UserRoles).HasForeignKey(ur => ur.RoleId);
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
// ---- RULE ----
|
| 88 |
+
model.Entity<Rule>(e =>
|
| 89 |
+
{
|
| 90 |
+
e.HasKey(r => r.Id);
|
| 91 |
+
e.HasIndex(r => new { r.TenantId, r.RuleCode }).IsUnique();
|
| 92 |
+
e.HasIndex(r => new { r.TenantId, r.RuleType }).HasFilter("is_active = true AND is_published = true");
|
| 93 |
+
e.Property(r => r.Tags)
|
| 94 |
+
.HasConversion(
|
| 95 |
+
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
| 96 |
+
v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null)!)
|
| 97 |
+
.HasColumnType("jsonb");
|
| 98 |
+
e.HasOne(r => r.Category).WithMany().HasForeignKey(r => r.CategoryId);
|
| 99 |
+
e.HasMany(r => r.Versions).WithOne(v => v.Rule).HasForeignKey(v => v.RuleId);
|
| 100 |
+
e.HasMany(r => r.Scopes).WithOne(s => s.Rule).HasForeignKey(s => s.RuleId);
|
| 101 |
+
e.HasOne(r => r.CurrentVersion).WithMany()
|
| 102 |
+
.HasForeignKey(r => r.CurrentVersionId)
|
| 103 |
+
.OnDelete(DeleteBehavior.SetNull);
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
// ---- RULE VERSION ----
|
| 107 |
+
model.Entity<RuleVersion>(e =>
|
| 108 |
+
{
|
| 109 |
+
e.HasKey(v => v.Id);
|
| 110 |
+
e.HasIndex(v => new { v.RuleId, v.VersionNumber }).IsUnique();
|
| 111 |
+
e.Property(v => v.RuleDefinition)
|
| 112 |
+
.HasConversion(
|
| 113 |
+
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
| 114 |
+
v => JsonSerializer.Deserialize<RuleDefinition>(v, (JsonSerializerOptions?)null)!)
|
| 115 |
+
.HasColumnType("jsonb");
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
// ---- RULE SCOPE ----
|
| 119 |
+
model.Entity<RuleScope>(e =>
|
| 120 |
+
{
|
| 121 |
+
e.HasKey(s => s.Id);
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
// ---- RULE SET ----
|
| 125 |
+
model.Entity<RuleSetMember>(e =>
|
| 126 |
+
{
|
| 127 |
+
e.HasKey(m => new { m.SetId, m.RuleId });
|
| 128 |
+
e.HasOne(m => m.RuleSet).WithMany(s => s.Members).HasForeignKey(m => m.SetId);
|
| 129 |
+
e.HasOne(m => m.Rule).WithMany().HasForeignKey(m => m.RuleId);
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
// ---- EXECUTION REQUEST ----
|
| 133 |
+
model.Entity<ExecutionRequest>(e =>
|
| 134 |
+
{
|
| 135 |
+
e.HasKey(r => r.Id);
|
| 136 |
+
e.HasIndex(r => r.CorrelationId).IsUnique();
|
| 137 |
+
e.HasIndex(r => new { r.TenantId, r.Status, r.CreatedAt });
|
| 138 |
+
e.Property(r => r.InputPayload)
|
| 139 |
+
.HasConversion(
|
| 140 |
+
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
| 141 |
+
v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, (JsonSerializerOptions?)null)!)
|
| 142 |
+
.HasColumnType("jsonb");
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
// ---- EXECUTION RESULT ----
|
| 146 |
+
model.Entity<ExecutionResult>(e =>
|
| 147 |
+
{
|
| 148 |
+
e.HasKey(r => r.Id);
|
| 149 |
+
e.HasIndex(r => r.RequestId);
|
| 150 |
+
e.Property(r => r.RuleResults)
|
| 151 |
+
.HasConversion(
|
| 152 |
+
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
| 153 |
+
v => JsonSerializer.Deserialize<List<RuleExecutionSummary>>(v, (JsonSerializerOptions?)null)!)
|
| 154 |
+
.HasColumnType("jsonb");
|
| 155 |
+
e.Property(r => r.FieldValues)
|
| 156 |
+
.HasConversion(
|
| 157 |
+
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
| 158 |
+
v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, (JsonSerializerOptions?)null)!)
|
| 159 |
+
.HasColumnType("jsonb");
|
| 160 |
+
e.Property(r => r.AiAnalysis)
|
| 161 |
+
.HasConversion(
|
| 162 |
+
v => v == null ? null : JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
| 163 |
+
v => v == null ? null : JsonSerializer.Deserialize<AiAnalysis>(v, (JsonSerializerOptions?)null))
|
| 164 |
+
.HasColumnType("jsonb");
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
// ---- AUDIT LOG ----
|
| 168 |
+
model.Entity<AuditLog>(e =>
|
| 169 |
+
{
|
| 170 |
+
e.HasKey(a => a.Id);
|
| 171 |
+
e.HasIndex(a => new { a.TenantId, a.EntityType, a.EntityId, a.CreatedAt });
|
| 172 |
+
e.Property(a => a.OldValues)
|
| 173 |
+
.HasConversion(
|
| 174 |
+
v => v == null ? null : JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
| 175 |
+
v => v == null ? null : JsonSerializer.Deserialize<Dictionary<string, object>>(v, (JsonSerializerOptions?)null))
|
| 176 |
+
.HasColumnType("jsonb");
|
| 177 |
+
e.Property(a => a.NewValues)
|
| 178 |
+
.HasConversion(
|
| 179 |
+
v => v == null ? null : JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
| 180 |
+
v => v == null ? null : JsonSerializer.Deserialize<Dictionary<string, object>>(v, (JsonSerializerOptions?)null))
|
| 181 |
+
.HasColumnType("jsonb");
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
// ---- DEVIATION ----
|
| 185 |
+
model.Entity<ExecutionDeviation>(e =>
|
| 186 |
+
{
|
| 187 |
+
e.HasKey(d => d.Id);
|
| 188 |
+
e.HasIndex(d => d.ResultId);
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
// Global query filters for soft-delete / tenant isolation
|
| 192 |
+
model.Entity<Rule>().HasQueryFilter(r => r.IsActive);
|
| 193 |
+
model.Entity<User>().HasQueryFilter(u => u.IsActive);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
|
| 197 |
+
{
|
| 198 |
+
UpdateTimestamps();
|
| 199 |
+
return base.SaveChangesAsync(ct);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
private void UpdateTimestamps()
|
| 203 |
+
{
|
| 204 |
+
foreach (var entry in ChangeTracker.Entries<BaseEntity>())
|
| 205 |
+
{
|
| 206 |
+
if (entry.State == EntityState.Modified)
|
| 207 |
+
entry.Entity.UpdatedAt = DateTime.UtcNow;
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// DB entity aliases needed for EF mapping
|
| 213 |
+
public class TenantConfiguration : TenantEntity
|
| 214 |
+
{
|
| 215 |
+
public string ConfigKey { get; set; } = default!;
|
| 216 |
+
public string? ConfigValue { get; set; }
|
| 217 |
+
public string ConfigType { get; set; } = "STRING";
|
| 218 |
+
public string? Category { get; set; }
|
| 219 |
+
public bool IsEncrypted { get; set; }
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
public class RefreshToken
|
| 223 |
+
{
|
| 224 |
+
public Guid Id { get; set; }
|
| 225 |
+
public Guid UserId { get; set; }
|
| 226 |
+
public string TokenHash { get; set; } = default!;
|
| 227 |
+
public DateTime ExpiresAt { get; set; }
|
| 228 |
+
public bool IsRevoked { get; set; }
|
| 229 |
+
public string? IpAddress { get; set; }
|
| 230 |
+
public string? UserAgent { get; set; }
|
| 231 |
+
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
public class Product : TenantEntity
|
| 235 |
+
{
|
| 236 |
+
public string ProductCode { get; set; } = default!;
|
| 237 |
+
public string ProductName { get; set; } = default!;
|
| 238 |
+
public string ProductType { get; set; } = default!;
|
| 239 |
+
public string? Description { get; set; }
|
| 240 |
+
public bool IsActive { get; set; } = true;
|
| 241 |
+
public Dictionary<string, object> Config { get; set; } = new();
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
public class Branch : TenantEntity
|
| 245 |
+
{
|
| 246 |
+
public string BranchCode { get; set; } = default!;
|
| 247 |
+
public string BranchName { get; set; } = default!;
|
| 248 |
+
public string? Region { get; set; }
|
| 249 |
+
public string? State { get; set; }
|
| 250 |
+
public string? City { get; set; }
|
| 251 |
+
public string? Zone { get; set; }
|
| 252 |
+
public bool IsActive { get; set; } = true;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
public class LoanStage : TenantEntity
|
| 256 |
+
{
|
| 257 |
+
public string StageCode { get; set; } = default!;
|
| 258 |
+
public string StageName { get; set; } = default!;
|
| 259 |
+
public int StageOrder { get; set; }
|
| 260 |
+
public string? Description { get; set; }
|
| 261 |
+
public bool IsActive { get; set; } = true;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
public class FieldCatalogEntry
|
| 265 |
+
{
|
| 266 |
+
public Guid Id { get; set; }
|
| 267 |
+
public Guid? TenantId { get; set; }
|
| 268 |
+
public string FieldPath { get; set; } = default!;
|
| 269 |
+
public string DisplayName { get; set; } = default!;
|
| 270 |
+
public string? Description { get; set; }
|
| 271 |
+
public string DataType { get; set; } = default!;
|
| 272 |
+
public string? Category { get; set; }
|
| 273 |
+
public bool IsSystemField { get; set; }
|
| 274 |
+
public bool IsActive { get; set; } = true;
|
| 275 |
+
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
public class RuleApproval
|
| 279 |
+
{
|
| 280 |
+
public Guid Id { get; set; }
|
| 281 |
+
public Guid RuleId { get; set; }
|
| 282 |
+
public Guid VersionId { get; set; }
|
| 283 |
+
public Guid TenantId { get; set; }
|
| 284 |
+
public Guid RequestedBy { get; set; }
|
| 285 |
+
public DateTime RequestedAt { get; set; } = DateTime.UtcNow;
|
| 286 |
+
public Guid? ReviewedBy { get; set; }
|
| 287 |
+
public DateTime? ReviewedAt { get; set; }
|
| 288 |
+
public string Status { get; set; } = "PENDING";
|
| 289 |
+
public string? Comments { get; set; }
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
public class RuleExecutionDetail
|
| 293 |
+
{
|
| 294 |
+
public Guid Id { get; set; }
|
| 295 |
+
public Guid ResultId { get; set; }
|
| 296 |
+
public Guid RuleId { get; set; }
|
| 297 |
+
public string RuleCode { get; set; } = default!;
|
| 298 |
+
public string RuleName { get; set; } = default!;
|
| 299 |
+
public int VersionNumber { get; set; }
|
| 300 |
+
public int ExecutionOrder { get; set; }
|
| 301 |
+
public bool IsMatched { get; set; }
|
| 302 |
+
public string ConditionsEvaluated { get; set; } = "[]";
|
| 303 |
+
public string ActionsExecuted { get; set; } = "[]";
|
| 304 |
+
public int? ExecutionMs { get; set; }
|
| 305 |
+
public string? ErrorMessage { get; set; }
|
| 306 |
+
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
public class DecisionReportEntity
|
| 310 |
+
{
|
| 311 |
+
public Guid Id { get; set; }
|
| 312 |
+
public Guid RequestId { get; set; }
|
| 313 |
+
public Guid TenantId { get; set; }
|
| 314 |
+
public string? ReportNumber { get; set; }
|
| 315 |
+
public string? ApplicationId { get; set; }
|
| 316 |
+
public string? ProductCode { get; set; }
|
| 317 |
+
public string FinalDecision { get; set; } = default!;
|
| 318 |
+
public decimal? RiskScore { get; set; }
|
| 319 |
+
public string? RiskCategory { get; set; }
|
| 320 |
+
public string? TrafficLight { get; set; }
|
| 321 |
+
public string? Summary { get; set; }
|
| 322 |
+
public string? ReportJson { get; set; }
|
| 323 |
+
public string? PdfStoragePath { get; set; }
|
| 324 |
+
public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
public class AiGeneratedRuleEntity
|
| 328 |
+
{
|
| 329 |
+
public Guid Id { get; set; }
|
| 330 |
+
public Guid TenantId { get; set; }
|
| 331 |
+
public string UserPrompt { get; set; } = default!;
|
| 332 |
+
public string GeneratedRule { get; set; } = "{}";
|
| 333 |
+
public Guid? RuleId { get; set; }
|
| 334 |
+
public bool? IsAccepted { get; set; }
|
| 335 |
+
public Guid CreatedBy { get; set; }
|
| 336 |
+
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
| 337 |
+
}
|
CachedRuleLoader.cs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.EntityFrameworkCore;
|
| 2 |
+
using Microsoft.Extensions.Caching.Distributed;
|
| 3 |
+
using Microsoft.Extensions.Logging;
|
| 4 |
+
using OptimAI.BRE.RuleEngine.Domain;
|
| 5 |
+
using OptimAI.BRE.Shared.Domain;
|
| 6 |
+
using System.Text.Json;
|
| 7 |
+
|
| 8 |
+
namespace OptimAI.BRE.RuleEngine.Infrastructure;
|
| 9 |
+
|
| 10 |
+
/// <summary>
|
| 11 |
+
/// Redis-backed rule loader with 5-minute cache TTL.
|
| 12 |
+
/// Cache key includes tenant + product + branch + stage for scope-aware loading.
|
| 13 |
+
/// </summary>
|
| 14 |
+
public sealed class CachedRuleLoader : IRuleLoader
|
| 15 |
+
{
|
| 16 |
+
private readonly BREDbContext _db;
|
| 17 |
+
private readonly IDistributedCache _cache;
|
| 18 |
+
private readonly ILogger<CachedRuleLoader> _logger;
|
| 19 |
+
|
| 20 |
+
private static readonly JsonSerializerOptions _jsonOpts = new()
|
| 21 |
+
{
|
| 22 |
+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
public CachedRuleLoader(BREDbContext db, IDistributedCache cache, ILogger<CachedRuleLoader> logger)
|
| 26 |
+
{
|
| 27 |
+
_db = db;
|
| 28 |
+
_cache = cache;
|
| 29 |
+
_logger = logger;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
public async Task<IReadOnlyList<Rule>> LoadRulesAsync(RuleLoadRequest request, CancellationToken ct = default)
|
| 33 |
+
{
|
| 34 |
+
var cacheKey = BuildCacheKey(request);
|
| 35 |
+
|
| 36 |
+
var cached = await _cache.GetStringAsync(cacheKey, ct);
|
| 37 |
+
if (cached != null)
|
| 38 |
+
{
|
| 39 |
+
_logger.LogDebug("Cache HIT for rules: {Key}", cacheKey);
|
| 40 |
+
return JsonSerializer.Deserialize<List<Rule>>(cached, _jsonOpts) ?? new();
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
_logger.LogDebug("Cache MISS for rules: {Key}", cacheKey);
|
| 44 |
+
var rules = await LoadFromDatabaseAsync(request, ct);
|
| 45 |
+
|
| 46 |
+
await _cache.SetStringAsync(cacheKey,
|
| 47 |
+
JsonSerializer.Serialize(rules, _jsonOpts),
|
| 48 |
+
new DistributedCacheEntryOptions
|
| 49 |
+
{
|
| 50 |
+
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
| 51 |
+
}, ct);
|
| 52 |
+
|
| 53 |
+
return rules;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
public async Task<Rule?> LoadRuleByCodeAsync(Guid tenantId, string ruleCode, CancellationToken ct = default)
|
| 57 |
+
{
|
| 58 |
+
return await _db.Rules
|
| 59 |
+
.Include(r => r.CurrentVersion)
|
| 60 |
+
.Include(r => r.Scopes)
|
| 61 |
+
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.RuleCode == ruleCode, ct);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
private async Task<List<Rule>> LoadFromDatabaseAsync(RuleLoadRequest request, CancellationToken ct)
|
| 65 |
+
{
|
| 66 |
+
var query = _db.Rules
|
| 67 |
+
.Include(r => r.CurrentVersion)
|
| 68 |
+
.Include(r => r.Scopes)
|
| 69 |
+
.Where(r => r.TenantId == request.TenantId);
|
| 70 |
+
|
| 71 |
+
if (request.PublishedOnly)
|
| 72 |
+
query = query.Where(r => r.IsPublished && r.Status == RuleStatus.Published);
|
| 73 |
+
|
| 74 |
+
if (request.RuleTypes.Any())
|
| 75 |
+
query = query.Where(r => request.RuleTypes.Contains(r.RuleType));
|
| 76 |
+
|
| 77 |
+
if (request.RuleSetId.HasValue)
|
| 78 |
+
{
|
| 79 |
+
var ruleIds = await _db.RuleSetMembers
|
| 80 |
+
.Where(m => m.SetId == request.RuleSetId.Value)
|
| 81 |
+
.Select(m => m.RuleId)
|
| 82 |
+
.ToListAsync(ct);
|
| 83 |
+
|
| 84 |
+
query = query.Where(r => ruleIds.Contains(r.Id));
|
| 85 |
+
}
|
| 86 |
+
else
|
| 87 |
+
{
|
| 88 |
+
// Scope filtering: include GLOBAL rules + rules scoped to the request context
|
| 89 |
+
query = query.Where(r =>
|
| 90 |
+
!r.Scopes.Any() || // no scope = global
|
| 91 |
+
r.Scopes.Any(s => s.ScopeType == ScopeType.Global) ||
|
| 92 |
+
(request.ProductCode != null && r.Scopes.Any(s =>
|
| 93 |
+
s.ScopeType == ScopeType.Product && s.ScopeValue == request.ProductCode && !s.IsExcluded)) ||
|
| 94 |
+
(request.BranchCode != null && r.Scopes.Any(s =>
|
| 95 |
+
s.ScopeType == ScopeType.Branch && s.ScopeValue == request.BranchCode && !s.IsExcluded)) ||
|
| 96 |
+
(request.StageCode != null && r.Scopes.Any(s =>
|
| 97 |
+
s.ScopeType == ScopeType.Stage && s.ScopeValue == request.StageCode && !s.IsExcluded))
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
return await query
|
| 102 |
+
.OrderBy(r => r.Priority)
|
| 103 |
+
.AsNoTracking()
|
| 104 |
+
.ToListAsync(ct);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
public async Task InvalidateCacheAsync(Guid tenantId, CancellationToken ct = default)
|
| 108 |
+
{
|
| 109 |
+
// Invalidate all rule cache entries for this tenant
|
| 110 |
+
// In production, use a tag-based cache invalidation strategy
|
| 111 |
+
var pattern = $"bre:rules:{tenantId}:*";
|
| 112 |
+
_logger.LogInformation("Invalidating rule cache for tenant {TenantId}", tenantId);
|
| 113 |
+
// Redis SCAN + DEL for pattern — implement via IConnectionMultiplexer if needed
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
private static string BuildCacheKey(RuleLoadRequest request)
|
| 117 |
+
{
|
| 118 |
+
var parts = new[]
|
| 119 |
+
{
|
| 120 |
+
"bre", "rules",
|
| 121 |
+
request.TenantId.ToString(),
|
| 122 |
+
request.ProductCode ?? "ALL",
|
| 123 |
+
request.BranchCode ?? "ALL",
|
| 124 |
+
request.StageCode ?? "ALL",
|
| 125 |
+
request.RuleSetId?.ToString() ?? "ALL",
|
| 126 |
+
string.Join(",", request.RuleTypes.OrderBy(t => t.ToString())),
|
| 127 |
+
request.PublishedOnly ? "pub" : "all"
|
| 128 |
+
};
|
| 129 |
+
return string.Join(":", parts);
|
| 130 |
+
}
|
| 131 |
+
}
|
ConditionEvaluator.cs
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Text.RegularExpressions;
|
| 2 |
+
using Microsoft.Extensions.Logging;
|
| 3 |
+
using OptimAI.BRE.RuleEngine.Domain;
|
| 4 |
+
using OptimAI.BRE.Shared.Domain;
|
| 5 |
+
|
| 6 |
+
namespace OptimAI.BRE.RuleEngine.Application;
|
| 7 |
+
|
| 8 |
+
public sealed class ConditionEvaluator : IConditionEvaluator
|
| 9 |
+
{
|
| 10 |
+
private readonly IDynamicFieldResolver _fieldResolver;
|
| 11 |
+
private readonly ILogger<ConditionEvaluator> _logger;
|
| 12 |
+
|
| 13 |
+
public ConditionEvaluator(IDynamicFieldResolver fieldResolver, ILogger<ConditionEvaluator> logger)
|
| 14 |
+
{
|
| 15 |
+
_fieldResolver = fieldResolver;
|
| 16 |
+
_logger = logger;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
public bool Evaluate(ConditionNode condition, DynamicDataContext context)
|
| 20 |
+
{
|
| 21 |
+
try
|
| 22 |
+
{
|
| 23 |
+
var actualValue = ResolveValue(condition.Field!, context);
|
| 24 |
+
var expectedValue = ResolveExpectedValue(condition, context);
|
| 25 |
+
|
| 26 |
+
return condition.Operator switch
|
| 27 |
+
{
|
| 28 |
+
ComparisonOperator.Equals => AreEqual(actualValue, expectedValue),
|
| 29 |
+
ComparisonOperator.NotEquals => !AreEqual(actualValue, expectedValue),
|
| 30 |
+
ComparisonOperator.GreaterThan => Compare(actualValue, expectedValue) > 0,
|
| 31 |
+
ComparisonOperator.GreaterThanOrEqual => Compare(actualValue, expectedValue) >= 0,
|
| 32 |
+
ComparisonOperator.LessThan => Compare(actualValue, expectedValue) < 0,
|
| 33 |
+
ComparisonOperator.LessThanOrEqual => Compare(actualValue, expectedValue) <= 0,
|
| 34 |
+
ComparisonOperator.Between => EvaluateBetween(actualValue, condition, context),
|
| 35 |
+
ComparisonOperator.NotBetween => !EvaluateBetween(actualValue, condition, context),
|
| 36 |
+
ComparisonOperator.In => EvaluateIn(actualValue, condition.Value),
|
| 37 |
+
ComparisonOperator.NotIn => !EvaluateIn(actualValue, condition.Value),
|
| 38 |
+
ComparisonOperator.Contains => EvaluateContains(actualValue, expectedValue),
|
| 39 |
+
ComparisonOperator.NotContains => !EvaluateContains(actualValue, expectedValue),
|
| 40 |
+
ComparisonOperator.StartsWith => EvaluateStartsWith(actualValue, expectedValue),
|
| 41 |
+
ComparisonOperator.EndsWith => EvaluateEndsWith(actualValue, expectedValue),
|
| 42 |
+
ComparisonOperator.IsNull => actualValue == null,
|
| 43 |
+
ComparisonOperator.IsNotNull => actualValue != null,
|
| 44 |
+
ComparisonOperator.IsTrue => IsTruthy(actualValue),
|
| 45 |
+
ComparisonOperator.IsFalse => !IsTruthy(actualValue),
|
| 46 |
+
ComparisonOperator.Regex => EvaluateRegex(actualValue, expectedValue?.ToString()),
|
| 47 |
+
_ => false
|
| 48 |
+
};
|
| 49 |
+
}
|
| 50 |
+
catch (Exception ex)
|
| 51 |
+
{
|
| 52 |
+
_logger.LogWarning(ex, "Condition evaluation failed for field {Field}", condition.Field);
|
| 53 |
+
return false;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
private object? ResolveValue(string field, DynamicDataContext context)
|
| 58 |
+
{
|
| 59 |
+
return _fieldResolver.TryResolve(field, context, out var value) ? value : null;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
private object? ResolveExpectedValue(ConditionNode condition, DynamicDataContext context)
|
| 63 |
+
{
|
| 64 |
+
return condition.ValueType switch
|
| 65 |
+
{
|
| 66 |
+
ValueType.Field when condition.ReferenceField != null => ResolveValue(condition.ReferenceField, context),
|
| 67 |
+
ValueType.Literal => condition.Value,
|
| 68 |
+
_ => condition.Value
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
private static bool AreEqual(object? left, object? right)
|
| 73 |
+
{
|
| 74 |
+
if (left == null && right == null) return true;
|
| 75 |
+
if (left == null || right == null) return false;
|
| 76 |
+
|
| 77 |
+
if (TryToDecimal(left, out var ld) && TryToDecimal(right, out var rd))
|
| 78 |
+
return ld == rd;
|
| 79 |
+
|
| 80 |
+
return left.ToString()?.Equals(right.ToString(), StringComparison.OrdinalIgnoreCase) ?? false;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
private static int Compare(object? left, object? right)
|
| 84 |
+
{
|
| 85 |
+
if (left == null && right == null) return 0;
|
| 86 |
+
if (left == null) return -1;
|
| 87 |
+
if (right == null) return 1;
|
| 88 |
+
|
| 89 |
+
if (TryToDecimal(left, out var ld) && TryToDecimal(right, out var rd))
|
| 90 |
+
return ld.CompareTo(rd);
|
| 91 |
+
|
| 92 |
+
if (left is DateTime dl && right is DateTime dr)
|
| 93 |
+
return dl.CompareTo(dr);
|
| 94 |
+
|
| 95 |
+
return string.Compare(left.ToString(), right.ToString(), StringComparison.OrdinalIgnoreCase);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
private bool EvaluateBetween(object? actual, ConditionNode condition, DynamicDataContext context)
|
| 99 |
+
{
|
| 100 |
+
var value1 = ResolveExpectedValue(condition, context);
|
| 101 |
+
var value2 = condition.Value2 != null ? (object)condition.Value2 : null;
|
| 102 |
+
|
| 103 |
+
if (value1 == null || value2 == null) return false;
|
| 104 |
+
return Compare(actual, value1) >= 0 && Compare(actual, value2) <= 0;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
private static bool EvaluateIn(object? actual, object? values)
|
| 108 |
+
{
|
| 109 |
+
if (actual == null || values == null) return false;
|
| 110 |
+
|
| 111 |
+
IEnumerable<object> list = values switch
|
| 112 |
+
{
|
| 113 |
+
IEnumerable<object> e => e,
|
| 114 |
+
string s => s.Split(',').Select(x => (object)x.Trim()),
|
| 115 |
+
_ => new[] { values }
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
return list.Any(v => AreEqual(actual, v));
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
private static bool EvaluateContains(object? actual, object? expected)
|
| 122 |
+
{
|
| 123 |
+
if (actual == null || expected == null) return false;
|
| 124 |
+
|
| 125 |
+
if (actual is IEnumerable<object> list)
|
| 126 |
+
return list.Any(item => AreEqual(item, expected));
|
| 127 |
+
|
| 128 |
+
return actual.ToString()?.Contains(expected.ToString() ?? "", StringComparison.OrdinalIgnoreCase) ?? false;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
private static bool EvaluateStartsWith(object? actual, object? expected)
|
| 132 |
+
=> actual?.ToString()?.StartsWith(expected?.ToString() ?? "", StringComparison.OrdinalIgnoreCase) ?? false;
|
| 133 |
+
|
| 134 |
+
private static bool EvaluateEndsWith(object? actual, object? expected)
|
| 135 |
+
=> actual?.ToString()?.EndsWith(expected?.ToString() ?? "", StringComparison.OrdinalIgnoreCase) ?? false;
|
| 136 |
+
|
| 137 |
+
private static bool EvaluateRegex(object? actual, string? pattern)
|
| 138 |
+
{
|
| 139 |
+
if (actual == null || pattern == null) return false;
|
| 140 |
+
return Regex.IsMatch(actual.ToString()!, pattern, RegexOptions.IgnoreCase);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
private static bool IsTruthy(object? value) => value switch
|
| 144 |
+
{
|
| 145 |
+
bool b => b,
|
| 146 |
+
decimal d => d != 0,
|
| 147 |
+
int i => i != 0,
|
| 148 |
+
string s => !string.IsNullOrEmpty(s) && s != "0" && s.ToLower() != "false",
|
| 149 |
+
null => false,
|
| 150 |
+
_ => true
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
private static bool TryToDecimal(object value, out decimal result)
|
| 154 |
+
{
|
| 155 |
+
result = 0;
|
| 156 |
+
return value switch
|
| 157 |
+
{
|
| 158 |
+
decimal d => (result = d) == d,
|
| 159 |
+
double d => (result = (decimal)d) == result,
|
| 160 |
+
float f => (result = (decimal)f) == result,
|
| 161 |
+
int i => (result = i) == i,
|
| 162 |
+
long l => (result = l) == l,
|
| 163 |
+
string s => decimal.TryParse(s, out result),
|
| 164 |
+
_ => false
|
| 165 |
+
};
|
| 166 |
+
}
|
| 167 |
+
}
|
ConditionGroupEditor.tsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React from 'react'
|
| 4 |
+
import { v4 as uuidv4 } from 'uuid'
|
| 5 |
+
import type { ConditionGroup, ConditionNode, FieldCatalogEntry, LogicalOperator, ComparisonOperator } from '@/types'
|
| 6 |
+
import { ConditionNodeEditor } from './ConditionNodeEditor'
|
| 7 |
+
|
| 8 |
+
interface Props {
|
| 9 |
+
group: ConditionGroup
|
| 10 |
+
fieldCatalog: FieldCatalogEntry[]
|
| 11 |
+
onChange: (group: ConditionGroup) => void
|
| 12 |
+
readOnly?: boolean
|
| 13 |
+
depth: number
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const GROUP_COLORS = [
|
| 17 |
+
'border-blue-300 bg-blue-50',
|
| 18 |
+
'border-purple-300 bg-purple-50',
|
| 19 |
+
'border-teal-300 bg-teal-50',
|
| 20 |
+
'border-orange-300 bg-orange-50',
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
export function ConditionGroupEditor({ group, fieldCatalog, onChange, readOnly, depth }: Props) {
|
| 24 |
+
const colorClass = GROUP_COLORS[depth % GROUP_COLORS.length]
|
| 25 |
+
|
| 26 |
+
const updateOperator = (operator: LogicalOperator) =>
|
| 27 |
+
onChange({ ...group, operator })
|
| 28 |
+
|
| 29 |
+
const addCondition = () => {
|
| 30 |
+
const newNode: ConditionNode = {
|
| 31 |
+
id: uuidv4(),
|
| 32 |
+
isGroup: false,
|
| 33 |
+
field: '',
|
| 34 |
+
operator: 'EQUALS',
|
| 35 |
+
value: '',
|
| 36 |
+
valueType: 'LITERAL',
|
| 37 |
+
}
|
| 38 |
+
onChange({ ...group, rules: [...group.rules, newNode] })
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const addGroup = () => {
|
| 42 |
+
const nestedGroup: ConditionGroup = {
|
| 43 |
+
id: uuidv4(),
|
| 44 |
+
operator: 'AND',
|
| 45 |
+
rules: [],
|
| 46 |
+
}
|
| 47 |
+
const newNode: ConditionNode = {
|
| 48 |
+
id: uuidv4(),
|
| 49 |
+
isGroup: true,
|
| 50 |
+
group: nestedGroup,
|
| 51 |
+
}
|
| 52 |
+
onChange({ ...group, rules: [...group.rules, newNode] })
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const updateNode = (index: number, updated: ConditionNode) => {
|
| 56 |
+
const next = [...group.rules]
|
| 57 |
+
next[index] = updated
|
| 58 |
+
onChange({ ...group, rules: next })
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const deleteNode = (index: number) => {
|
| 62 |
+
onChange({ ...group, rules: group.rules.filter((_, i) => i !== index) })
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<div className={`rounded-lg border-2 ${colorClass} p-4`}>
|
| 67 |
+
{/* Group Header */}
|
| 68 |
+
<div className="flex items-center gap-3 mb-4">
|
| 69 |
+
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
| 70 |
+
Match
|
| 71 |
+
</span>
|
| 72 |
+
<div className="flex rounded-lg overflow-hidden border border-gray-200">
|
| 73 |
+
{(['AND', 'OR', 'NOT'] as LogicalOperator[]).map((op) => (
|
| 74 |
+
<button
|
| 75 |
+
key={op}
|
| 76 |
+
disabled={readOnly}
|
| 77 |
+
onClick={() => updateOperator(op)}
|
| 78 |
+
className={`px-3 py-1 text-xs font-bold transition-colors ${
|
| 79 |
+
group.operator === op
|
| 80 |
+
? op === 'AND'
|
| 81 |
+
? 'bg-blue-600 text-white'
|
| 82 |
+
: op === 'OR'
|
| 83 |
+
? 'bg-purple-600 text-white'
|
| 84 |
+
: 'bg-red-600 text-white'
|
| 85 |
+
: 'bg-white text-gray-600 hover:bg-gray-50'
|
| 86 |
+
}`}
|
| 87 |
+
>
|
| 88 |
+
{op}
|
| 89 |
+
</button>
|
| 90 |
+
))}
|
| 91 |
+
</div>
|
| 92 |
+
<span className="text-xs text-gray-400">
|
| 93 |
+
{group.operator === 'AND' ? 'all of the following' : group.operator === 'OR' ? 'any of the following' : 'NOT the following'}
|
| 94 |
+
</span>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
{/* Conditions */}
|
| 98 |
+
<div className="space-y-2">
|
| 99 |
+
{group.rules.length === 0 && (
|
| 100 |
+
<div className="text-center py-4 text-gray-400 text-sm">
|
| 101 |
+
{readOnly ? 'No conditions' : 'Add conditions below'}
|
| 102 |
+
</div>
|
| 103 |
+
)}
|
| 104 |
+
{group.rules.map((node, idx) => (
|
| 105 |
+
<div key={node.id} className="flex items-start gap-2">
|
| 106 |
+
{idx > 0 && (
|
| 107 |
+
<div className="mt-3 px-2 py-0.5 rounded text-xs font-bold text-gray-400 min-w-[36px] text-center">
|
| 108 |
+
{group.operator}
|
| 109 |
+
</div>
|
| 110 |
+
)}
|
| 111 |
+
<div className={`flex-1 ${idx === 0 ? '' : ''}`}>
|
| 112 |
+
{node.isGroup && node.group ? (
|
| 113 |
+
<ConditionGroupEditor
|
| 114 |
+
group={node.group}
|
| 115 |
+
fieldCatalog={fieldCatalog}
|
| 116 |
+
depth={depth + 1}
|
| 117 |
+
readOnly={readOnly}
|
| 118 |
+
onChange={(updated) =>
|
| 119 |
+
updateNode(idx, { ...node, group: updated })
|
| 120 |
+
}
|
| 121 |
+
/>
|
| 122 |
+
) : (
|
| 123 |
+
<ConditionNodeEditor
|
| 124 |
+
node={node}
|
| 125 |
+
fieldCatalog={fieldCatalog}
|
| 126 |
+
readOnly={readOnly}
|
| 127 |
+
onChange={(updated) => updateNode(idx, updated)}
|
| 128 |
+
onDelete={() => deleteNode(idx)}
|
| 129 |
+
/>
|
| 130 |
+
)}
|
| 131 |
+
</div>
|
| 132 |
+
{!readOnly && (
|
| 133 |
+
<button
|
| 134 |
+
onClick={() => deleteNode(idx)}
|
| 135 |
+
className="mt-2 text-gray-300 hover:text-red-500 transition-colors"
|
| 136 |
+
title="Delete condition"
|
| 137 |
+
>
|
| 138 |
+
✕
|
| 139 |
+
</button>
|
| 140 |
+
)}
|
| 141 |
+
</div>
|
| 142 |
+
))}
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
{/* Add Buttons */}
|
| 146 |
+
{!readOnly && (
|
| 147 |
+
<div className="flex items-center gap-2 mt-4">
|
| 148 |
+
<button
|
| 149 |
+
onClick={addCondition}
|
| 150 |
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-100 hover:bg-blue-200 rounded-lg transition-colors"
|
| 151 |
+
>
|
| 152 |
+
<span className="text-base leading-none">+</span> Add Condition
|
| 153 |
+
</button>
|
| 154 |
+
{depth < 3 && (
|
| 155 |
+
<button
|
| 156 |
+
onClick={addGroup}
|
| 157 |
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-purple-700 bg-purple-100 hover:bg-purple-200 rounded-lg transition-colors"
|
| 158 |
+
>
|
| 159 |
+
<span className="text-base leading-none">⊞</span> Add Group
|
| 160 |
+
</button>
|
| 161 |
+
)}
|
| 162 |
+
</div>
|
| 163 |
+
)}
|
| 164 |
+
</div>
|
| 165 |
+
)
|
| 166 |
+
}
|
ConditionNodeEditor.tsx
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react'
|
| 4 |
+
import type { ConditionNode, FieldCatalogEntry, ComparisonOperator } from '@/types'
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
node: ConditionNode
|
| 8 |
+
fieldCatalog: FieldCatalogEntry[]
|
| 9 |
+
onChange: (node: ConditionNode) => void
|
| 10 |
+
onDelete: () => void
|
| 11 |
+
readOnly?: boolean
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const OPERATORS: { value: ComparisonOperator; label: string; showValue: 'single' | 'double' | 'none' | 'multi' }[] = [
|
| 15 |
+
{ value: 'EQUALS', label: '= Equals', showValue: 'single' },
|
| 16 |
+
{ value: 'NOT_EQUALS', label: '≠ Not Equals', showValue: 'single' },
|
| 17 |
+
{ value: 'GREATER_THAN', label: '> Greater Than', showValue: 'single' },
|
| 18 |
+
{ value: 'GREATER_THAN_OR_EQUAL', label: '≥ Greater Than or Equal', showValue: 'single' },
|
| 19 |
+
{ value: 'LESS_THAN', label: '< Less Than', showValue: 'single' },
|
| 20 |
+
{ value: 'LESS_THAN_OR_EQUAL', label: '≤ Less Than or Equal', showValue: 'single' },
|
| 21 |
+
{ value: 'BETWEEN', label: '↔ Between', showValue: 'double' },
|
| 22 |
+
{ value: 'IN', label: '∈ In (comma separated)', showValue: 'single' },
|
| 23 |
+
{ value: 'NOT_IN', label: '∉ Not In', showValue: 'single' },
|
| 24 |
+
{ value: 'CONTAINS', label: '⊆ Contains', showValue: 'single' },
|
| 25 |
+
{ value: 'IS_NULL', label: '∅ Is Empty/Null', showValue: 'none' },
|
| 26 |
+
{ value: 'IS_NOT_NULL', label: '• Is Not Null', showValue: 'none' },
|
| 27 |
+
{ value: 'IS_TRUE', label: '✓ Is True', showValue: 'none' },
|
| 28 |
+
{ value: 'IS_FALSE', label: '✗ Is False', showValue: 'none' },
|
| 29 |
+
{ value: 'REGEX', label: '⚙ Matches Regex', showValue: 'single' },
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
const FIELD_CATEGORIES = ['BUREAU', 'INCOME', 'PERSONAL', 'EMPLOYMENT', 'VEHICLE', 'FI', 'LOAN', 'RATIOS', 'FRAUD', 'GST', 'ITR', 'KYC']
|
| 33 |
+
|
| 34 |
+
export function ConditionNodeEditor({ node, fieldCatalog, onChange, onDelete, readOnly }: Props) {
|
| 35 |
+
const [showFieldSearch, setShowFieldSearch] = useState(false)
|
| 36 |
+
const [fieldSearch, setFieldSearch] = useState('')
|
| 37 |
+
|
| 38 |
+
const selectedOp = OPERATORS.find((o) => o.value === node.operator)
|
| 39 |
+
const selectedField = fieldCatalog.find((f) => f.fieldPath === node.field)
|
| 40 |
+
|
| 41 |
+
const filteredFields = fieldCatalog.filter(
|
| 42 |
+
(f) =>
|
| 43 |
+
!fieldSearch ||
|
| 44 |
+
f.fieldPath.toLowerCase().includes(fieldSearch.toLowerCase()) ||
|
| 45 |
+
f.displayName.toLowerCase().includes(fieldSearch.toLowerCase())
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
const groupedFields = FIELD_CATEGORIES.reduce<Record<string, FieldCatalogEntry[]>>((acc, cat) => {
|
| 49 |
+
const fields = filteredFields.filter((f) => f.category === cat)
|
| 50 |
+
if (fields.length > 0) acc[cat] = fields
|
| 51 |
+
return acc
|
| 52 |
+
}, {})
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<div className="flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-2 group hover:border-blue-300 transition-colors">
|
| 56 |
+
{/* Field Selector */}
|
| 57 |
+
<div className="relative min-w-[220px]">
|
| 58 |
+
<button
|
| 59 |
+
disabled={readOnly}
|
| 60 |
+
onClick={() => !readOnly && setShowFieldSearch(!showFieldSearch)}
|
| 61 |
+
className="w-full text-left text-sm px-2 py-1 rounded border border-gray-200 hover:border-blue-400 focus:outline-none focus:border-blue-500 bg-gray-50"
|
| 62 |
+
>
|
| 63 |
+
{selectedField ? (
|
| 64 |
+
<span className="text-gray-800">{selectedField.displayName}</span>
|
| 65 |
+
) : (
|
| 66 |
+
<span className="text-gray-400">Select field...</span>
|
| 67 |
+
)}
|
| 68 |
+
</button>
|
| 69 |
+
|
| 70 |
+
{showFieldSearch && !readOnly && (
|
| 71 |
+
<div className="absolute top-full left-0 mt-1 w-80 bg-white border border-gray-200 rounded-xl shadow-xl z-50 max-h-72 overflow-auto">
|
| 72 |
+
<div className="p-2 border-b sticky top-0 bg-white">
|
| 73 |
+
<input
|
| 74 |
+
autoFocus
|
| 75 |
+
value={fieldSearch}
|
| 76 |
+
onChange={(e) => setFieldSearch(e.target.value)}
|
| 77 |
+
placeholder="Search fields..."
|
| 78 |
+
className="w-full text-sm px-2 py-1.5 border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400"
|
| 79 |
+
/>
|
| 80 |
+
</div>
|
| 81 |
+
{Object.entries(groupedFields).map(([cat, fields]) => (
|
| 82 |
+
<div key={cat}>
|
| 83 |
+
<div className="px-3 py-1 text-xs font-semibold text-gray-400 uppercase tracking-wider bg-gray-50">
|
| 84 |
+
{cat}
|
| 85 |
+
</div>
|
| 86 |
+
{fields.map((f) => (
|
| 87 |
+
<button
|
| 88 |
+
key={f.fieldPath}
|
| 89 |
+
onClick={() => {
|
| 90 |
+
onChange({ ...node, field: f.fieldPath })
|
| 91 |
+
setShowFieldSearch(false)
|
| 92 |
+
setFieldSearch('')
|
| 93 |
+
}}
|
| 94 |
+
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 hover:text-blue-700 flex items-center justify-between"
|
| 95 |
+
>
|
| 96 |
+
<span>{f.displayName}</span>
|
| 97 |
+
<span className="text-xs text-gray-400 font-mono">{f.dataType}</span>
|
| 98 |
+
</button>
|
| 99 |
+
))}
|
| 100 |
+
</div>
|
| 101 |
+
))}
|
| 102 |
+
</div>
|
| 103 |
+
)}
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Operator Selector */}
|
| 107 |
+
<select
|
| 108 |
+
disabled={readOnly}
|
| 109 |
+
value={node.operator ?? 'EQUALS'}
|
| 110 |
+
onChange={(e) => onChange({ ...node, operator: e.target.value as ComparisonOperator })}
|
| 111 |
+
className="text-sm border border-gray-200 rounded-lg px-2 py-1 bg-gray-50 focus:outline-none focus:border-blue-500 min-w-[160px]"
|
| 112 |
+
>
|
| 113 |
+
{OPERATORS.map((op) => (
|
| 114 |
+
<option key={op.value} value={op.value}>{op.label}</option>
|
| 115 |
+
))}
|
| 116 |
+
</select>
|
| 117 |
+
|
| 118 |
+
{/* Value Input(s) */}
|
| 119 |
+
{selectedOp?.showValue === 'single' && (
|
| 120 |
+
<ValueInput
|
| 121 |
+
value={String(node.value ?? '')}
|
| 122 |
+
fieldType={selectedField?.dataType}
|
| 123 |
+
readOnly={readOnly}
|
| 124 |
+
onChange={(v) => onChange({ ...node, value: v })}
|
| 125 |
+
placeholder="Value"
|
| 126 |
+
/>
|
| 127 |
+
)}
|
| 128 |
+
|
| 129 |
+
{selectedOp?.showValue === 'double' && (
|
| 130 |
+
<>
|
| 131 |
+
<ValueInput
|
| 132 |
+
value={String(node.value ?? '')}
|
| 133 |
+
fieldType={selectedField?.dataType}
|
| 134 |
+
readOnly={readOnly}
|
| 135 |
+
onChange={(v) => onChange({ ...node, value: v })}
|
| 136 |
+
placeholder="From"
|
| 137 |
+
/>
|
| 138 |
+
<span className="text-gray-400 text-sm">and</span>
|
| 139 |
+
<ValueInput
|
| 140 |
+
value={String(node.value2 ?? '')}
|
| 141 |
+
fieldType={selectedField?.dataType}
|
| 142 |
+
readOnly={readOnly}
|
| 143 |
+
onChange={(v) => onChange({ ...node, value2: v })}
|
| 144 |
+
placeholder="To"
|
| 145 |
+
/>
|
| 146 |
+
</>
|
| 147 |
+
)}
|
| 148 |
+
|
| 149 |
+
{/* Field badge */}
|
| 150 |
+
{node.field && (
|
| 151 |
+
<span className="text-xs font-mono text-gray-400 truncate max-w-[120px]" title={node.field}>
|
| 152 |
+
{node.field}
|
| 153 |
+
</span>
|
| 154 |
+
)}
|
| 155 |
+
</div>
|
| 156 |
+
)
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
function ValueInput({
|
| 160 |
+
value,
|
| 161 |
+
fieldType,
|
| 162 |
+
onChange,
|
| 163 |
+
placeholder,
|
| 164 |
+
readOnly,
|
| 165 |
+
}: {
|
| 166 |
+
value: string
|
| 167 |
+
fieldType?: string
|
| 168 |
+
onChange: (v: string) => void
|
| 169 |
+
placeholder: string
|
| 170 |
+
readOnly?: boolean
|
| 171 |
+
}) {
|
| 172 |
+
if (fieldType === 'BOOLEAN') {
|
| 173 |
+
return (
|
| 174 |
+
<select
|
| 175 |
+
disabled={readOnly}
|
| 176 |
+
value={value}
|
| 177 |
+
onChange={(e) => onChange(e.target.value)}
|
| 178 |
+
className="text-sm border border-gray-200 rounded-lg px-2 py-1 bg-gray-50 focus:outline-none focus:border-blue-500"
|
| 179 |
+
>
|
| 180 |
+
<option value="">Select...</option>
|
| 181 |
+
<option value="true">True</option>
|
| 182 |
+
<option value="false">False</option>
|
| 183 |
+
</select>
|
| 184 |
+
)
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
return (
|
| 188 |
+
<input
|
| 189 |
+
type={fieldType === 'NUMBER' ? 'number' : fieldType === 'DATE' ? 'date' : 'text'}
|
| 190 |
+
disabled={readOnly}
|
| 191 |
+
value={value}
|
| 192 |
+
onChange={(e) => onChange(e.target.value)}
|
| 193 |
+
placeholder={placeholder}
|
| 194 |
+
className="text-sm border border-gray-200 rounded-lg px-2 py-1.5 bg-gray-50 focus:outline-none focus:border-blue-500 w-32"
|
| 195 |
+
/>
|
| 196 |
+
)
|
| 197 |
+
}
|
DashboardPage.tsx
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React from 'react'
|
| 4 |
+
import { useQuery } from '@tanstack/react-query'
|
| 5 |
+
import {
|
| 6 |
+
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
|
| 7 |
+
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend
|
| 8 |
+
} from 'recharts'
|
| 9 |
+
import type { DashboardStats, ExecutionTrend, RuleHitAnalysis, DeviationAnalysisSummary } from '@/types'
|
| 10 |
+
import { apiClient } from '@/lib/api-client'
|
| 11 |
+
|
| 12 |
+
const TRAFFIC_COLORS = {
|
| 13 |
+
approved: '#10b981',
|
| 14 |
+
rejected: '#ef4444',
|
| 15 |
+
deviations: '#f59e0b',
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function DashboardPage() {
|
| 19 |
+
const { data: stats } = useQuery<DashboardStats>({
|
| 20 |
+
queryKey: ['dashboard-stats'],
|
| 21 |
+
queryFn: () => apiClient.get('/analytics/dashboard-stats'),
|
| 22 |
+
refetchInterval: 30_000,
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
const { data: trends } = useQuery<ExecutionTrend[]>({
|
| 26 |
+
queryKey: ['execution-trends'],
|
| 27 |
+
queryFn: () => apiClient.get('/analytics/execution-trends?days=30'),
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
const { data: topRules } = useQuery<RuleHitAnalysis[]>({
|
| 31 |
+
queryKey: ['rule-hit-analysis'],
|
| 32 |
+
queryFn: () => apiClient.get('/analytics/rule-hit-analysis?limit=10'),
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
const { data: deviations } = useQuery<DeviationAnalysisSummary[]>({
|
| 36 |
+
queryKey: ['deviation-analysis'],
|
| 37 |
+
queryFn: () => apiClient.get('/analytics/deviation-analysis'),
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
const decisionData = stats
|
| 41 |
+
? [
|
| 42 |
+
{ name: 'Approved', value: stats.approvalRate, color: '#10b981' },
|
| 43 |
+
{ name: 'Rejected', value: stats.rejectionRate, color: '#ef4444' },
|
| 44 |
+
{ name: 'Deviation', value: stats.deviationRate, color: '#f59e0b' },
|
| 45 |
+
]
|
| 46 |
+
: []
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className="p-6 space-y-6">
|
| 50 |
+
{/* Header */}
|
| 51 |
+
<div className="flex items-center justify-between">
|
| 52 |
+
<div>
|
| 53 |
+
<h1 className="text-2xl font-bold text-gray-900">OPTIM AI BRE Dashboard</h1>
|
| 54 |
+
<p className="text-gray-500 text-sm mt-1">Real-time rule engine analytics and insights</p>
|
| 55 |
+
</div>
|
| 56 |
+
<div className="flex items-center gap-2">
|
| 57 |
+
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
| 58 |
+
<span className="text-sm text-gray-500">Live</span>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
{/* KPI Cards */}
|
| 63 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 64 |
+
<KPICard
|
| 65 |
+
label="Total Executions Today"
|
| 66 |
+
value={stats?.todayExecutions?.toLocaleString() ?? '—'}
|
| 67 |
+
sub={`${stats?.totalExecutions?.toLocaleString() ?? '—'} total`}
|
| 68 |
+
color="blue"
|
| 69 |
+
icon="⚡"
|
| 70 |
+
/>
|
| 71 |
+
<KPICard
|
| 72 |
+
label="Approval Rate"
|
| 73 |
+
value={`${stats?.approvalRate?.toFixed(1) ?? '—'}%`}
|
| 74 |
+
sub="GREEN decisions"
|
| 75 |
+
color="emerald"
|
| 76 |
+
icon="✅"
|
| 77 |
+
/>
|
| 78 |
+
<KPICard
|
| 79 |
+
label="Rejection Rate"
|
| 80 |
+
value={`${stats?.rejectionRate?.toFixed(1) ?? '—'}%`}
|
| 81 |
+
sub="RED decisions"
|
| 82 |
+
color="red"
|
| 83 |
+
icon="🚫"
|
| 84 |
+
/>
|
| 85 |
+
<KPICard
|
| 86 |
+
label="Avg Risk Score"
|
| 87 |
+
value={stats?.avgRiskScore?.toFixed(1) ?? '—'}
|
| 88 |
+
sub="/100"
|
| 89 |
+
color="amber"
|
| 90 |
+
icon="📊"
|
| 91 |
+
/>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 95 |
+
<KPICard
|
| 96 |
+
label="Published Rules"
|
| 97 |
+
value={stats?.publishedRules?.toLocaleString() ?? '—'}
|
| 98 |
+
sub={`${stats?.activeRules ?? '—'} active`}
|
| 99 |
+
color="purple"
|
| 100 |
+
icon="📜"
|
| 101 |
+
/>
|
| 102 |
+
<KPICard
|
| 103 |
+
label="Deviation Rate"
|
| 104 |
+
value={`${stats?.deviationRate?.toFixed(1) ?? '—'}%`}
|
| 105 |
+
sub="AMBER decisions"
|
| 106 |
+
color="orange"
|
| 107 |
+
icon="⚠️"
|
| 108 |
+
/>
|
| 109 |
+
<KPICard
|
| 110 |
+
label="Avg Execution Time"
|
| 111 |
+
value={`${stats?.avgExecutionMs?.toFixed(0) ?? '—'}ms`}
|
| 112 |
+
sub="per decision"
|
| 113 |
+
color="teal"
|
| 114 |
+
icon="⏱️"
|
| 115 |
+
/>
|
| 116 |
+
<KPICard
|
| 117 |
+
label="Total Rules"
|
| 118 |
+
value={stats?.totalRules?.toLocaleString() ?? '—'}
|
| 119 |
+
sub={`${stats?.draftRules ?? '—'} in draft`}
|
| 120 |
+
color="indigo"
|
| 121 |
+
icon="⚙️"
|
| 122 |
+
/>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
{/* Charts Row 1 */}
|
| 126 |
+
<div className="grid grid-cols-3 gap-6">
|
| 127 |
+
{/* Execution Trend */}
|
| 128 |
+
<div className="col-span-2 bg-white rounded-xl border border-gray-200 p-5">
|
| 129 |
+
<h2 className="text-base font-semibold text-gray-800 mb-4">Decision Trend (30 Days)</h2>
|
| 130 |
+
<ResponsiveContainer width="100%" height={220}>
|
| 131 |
+
<LineChart data={trends ?? []}>
|
| 132 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
| 133 |
+
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
| 134 |
+
<YAxis tick={{ fontSize: 11 }} />
|
| 135 |
+
<Tooltip />
|
| 136 |
+
<Legend />
|
| 137 |
+
<Line type="monotone" dataKey="approved" name="Approved" stroke="#10b981" strokeWidth={2} dot={false} />
|
| 138 |
+
<Line type="monotone" dataKey="rejected" name="Rejected" stroke="#ef4444" strokeWidth={2} dot={false} />
|
| 139 |
+
<Line type="monotone" dataKey="deviations" name="Deviations" stroke="#f59e0b" strokeWidth={2} dot={false} />
|
| 140 |
+
</LineChart>
|
| 141 |
+
</ResponsiveContainer>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
{/* Decision Distribution */}
|
| 145 |
+
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
| 146 |
+
<h2 className="text-base font-semibold text-gray-800 mb-4">Decision Distribution</h2>
|
| 147 |
+
<ResponsiveContainer width="100%" height={180}>
|
| 148 |
+
<PieChart>
|
| 149 |
+
<Pie
|
| 150 |
+
data={decisionData}
|
| 151 |
+
cx="50%"
|
| 152 |
+
cy="50%"
|
| 153 |
+
innerRadius={50}
|
| 154 |
+
outerRadius={80}
|
| 155 |
+
paddingAngle={3}
|
| 156 |
+
dataKey="value"
|
| 157 |
+
>
|
| 158 |
+
{decisionData.map((entry, idx) => (
|
| 159 |
+
<Cell key={idx} fill={entry.color} />
|
| 160 |
+
))}
|
| 161 |
+
</Pie>
|
| 162 |
+
<Tooltip formatter={(v) => `${Number(v).toFixed(1)}%`} />
|
| 163 |
+
</PieChart>
|
| 164 |
+
</ResponsiveContainer>
|
| 165 |
+
<div className="space-y-1 mt-2">
|
| 166 |
+
{decisionData.map((d) => (
|
| 167 |
+
<div key={d.name} className="flex items-center justify-between text-sm">
|
| 168 |
+
<div className="flex items-center gap-2">
|
| 169 |
+
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: d.color }} />
|
| 170 |
+
<span className="text-gray-600">{d.name}</span>
|
| 171 |
+
</div>
|
| 172 |
+
<span className="font-semibold text-gray-800">{d.value?.toFixed(1)}%</span>
|
| 173 |
+
</div>
|
| 174 |
+
))}
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
{/* Charts Row 2 */}
|
| 180 |
+
<div className="grid grid-cols-2 gap-6">
|
| 181 |
+
{/* Top Rules by Hit Rate */}
|
| 182 |
+
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
| 183 |
+
<h2 className="text-base font-semibold text-gray-800 mb-4">Top Rules by Hit Rate</h2>
|
| 184 |
+
<ResponsiveContainer width="100%" height={220}>
|
| 185 |
+
<BarChart data={topRules?.slice(0, 8) ?? []} layout="vertical">
|
| 186 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
| 187 |
+
<XAxis type="number" tick={{ fontSize: 11 }} unit="%" />
|
| 188 |
+
<YAxis type="category" dataKey="ruleName" tick={{ fontSize: 10 }} width={120} />
|
| 189 |
+
<Tooltip formatter={(v) => `${Number(v).toFixed(1)}%`} />
|
| 190 |
+
<Bar dataKey="hitRate" name="Hit Rate" fill="#6366f1" radius={[0, 4, 4, 0]} />
|
| 191 |
+
</BarChart>
|
| 192 |
+
</ResponsiveContainer>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
{/* Top Deviations */}
|
| 196 |
+
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
| 197 |
+
<h2 className="text-base font-semibold text-gray-800 mb-1">Top Deviations</h2>
|
| 198 |
+
<p className="text-xs text-gray-400 mb-4">Most frequent policy deviations</p>
|
| 199 |
+
<div className="space-y-3 max-h-60 overflow-auto">
|
| 200 |
+
{(deviations ?? []).slice(0, 10).map((d, idx) => (
|
| 201 |
+
<div key={d.deviationCode} className="flex items-center gap-3">
|
| 202 |
+
<span className="text-xs font-bold text-gray-400 w-5">{idx + 1}</span>
|
| 203 |
+
<div className="flex-1">
|
| 204 |
+
<div className="flex items-center justify-between">
|
| 205 |
+
<span className="text-sm font-medium text-gray-700 truncate">{d.deviationName}</span>
|
| 206 |
+
<SeverityBadge severity={d.severity} />
|
| 207 |
+
</div>
|
| 208 |
+
<div className="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
| 209 |
+
<div
|
| 210 |
+
className="h-full bg-orange-400 rounded-full"
|
| 211 |
+
style={{ width: `${Math.min((d.occurrenceCount / ((deviations?.[0]?.occurrenceCount ?? 1))) * 100, 100)}%` }}
|
| 212 |
+
/>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
<span className="text-xs text-gray-500 font-mono">{d.occurrenceCount}</span>
|
| 216 |
+
</div>
|
| 217 |
+
))}
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
)
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
function KPICard({
|
| 226 |
+
label, value, sub, color, icon
|
| 227 |
+
}: {
|
| 228 |
+
label: string; value: string; sub: string; color: string; icon: string
|
| 229 |
+
}) {
|
| 230 |
+
const colorMap: Record<string, string> = {
|
| 231 |
+
blue: 'bg-blue-50 text-blue-700',
|
| 232 |
+
emerald: 'bg-emerald-50 text-emerald-700',
|
| 233 |
+
red: 'bg-red-50 text-red-700',
|
| 234 |
+
amber: 'bg-amber-50 text-amber-700',
|
| 235 |
+
purple: 'bg-purple-50 text-purple-700',
|
| 236 |
+
orange: 'bg-orange-50 text-orange-700',
|
| 237 |
+
teal: 'bg-teal-50 text-teal-700',
|
| 238 |
+
indigo: 'bg-indigo-50 text-indigo-700',
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
return (
|
| 242 |
+
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
| 243 |
+
<div className="flex items-start justify-between">
|
| 244 |
+
<div>
|
| 245 |
+
<p className="text-xs text-gray-500 font-medium">{label}</p>
|
| 246 |
+
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
|
| 247 |
+
<p className="text-xs text-gray-400 mt-0.5">{sub}</p>
|
| 248 |
+
</div>
|
| 249 |
+
<div className={`w-9 h-9 rounded-lg flex items-center justify-center text-lg ${colorMap[color] ?? 'bg-gray-50'}`}>
|
| 250 |
+
{icon}
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
)
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
function SeverityBadge({ severity }: { severity: string }) {
|
| 258 |
+
const colors: Record<string, string> = {
|
| 259 |
+
LOW: 'bg-blue-100 text-blue-700',
|
| 260 |
+
MEDIUM: 'bg-yellow-100 text-yellow-700',
|
| 261 |
+
HIGH: 'bg-orange-100 text-orange-700',
|
| 262 |
+
CRITICAL: 'bg-red-100 text-red-700',
|
| 263 |
+
}
|
| 264 |
+
return (
|
| 265 |
+
<span className={`text-xs px-1.5 py-0.5 rounded font-semibold ${colors[severity] ?? 'bg-gray-100'}`}>
|
| 266 |
+
{severity}
|
| 267 |
+
</span>
|
| 268 |
+
)
|
| 269 |
+
}
|
DeviationManagementPage.tsx
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react'
|
| 4 |
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
| 5 |
+
import type { PagedResponse } from '@/types'
|
| 6 |
+
import { apiClient } from '@/lib/api-client'
|
| 7 |
+
|
| 8 |
+
interface DeviationRecord {
|
| 9 |
+
id: string
|
| 10 |
+
requestId: string
|
| 11 |
+
applicationId?: string
|
| 12 |
+
deviationCode: string
|
| 13 |
+
deviationName: string
|
| 14 |
+
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
|
| 15 |
+
reason: string
|
| 16 |
+
fieldPath?: string
|
| 17 |
+
actualValue?: string
|
| 18 |
+
expectedValue?: string
|
| 19 |
+
recommendedAction?: string
|
| 20 |
+
isOverridden: boolean
|
| 21 |
+
overrideReason?: string
|
| 22 |
+
createdAt: string
|
| 23 |
+
ruleCode?: string
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
interface DeviationType {
|
| 27 |
+
id: string
|
| 28 |
+
deviationCode: string
|
| 29 |
+
deviationName: string
|
| 30 |
+
category: string
|
| 31 |
+
defaultSeverity: string
|
| 32 |
+
description: string
|
| 33 |
+
recommendedAction: string
|
| 34 |
+
requiresApproval: boolean
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const SEVERITY_CONFIG = {
|
| 38 |
+
LOW: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200', dot: 'bg-blue-500' },
|
| 39 |
+
MEDIUM: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200', dot: 'bg-yellow-500' },
|
| 40 |
+
HIGH: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200', dot: 'bg-orange-500' },
|
| 41 |
+
CRITICAL: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', dot: 'bg-red-500' },
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function DeviationManagementPage() {
|
| 45 |
+
const qc = useQueryClient()
|
| 46 |
+
const [activeTab, setActiveTab] = useState<'live' | 'types'>('live')
|
| 47 |
+
const [selectedSeverity, setSelectedSeverity] = useState<string>('')
|
| 48 |
+
const [search, setSearch] = useState('')
|
| 49 |
+
const [overrideModal, setOverrideModal] = useState<DeviationRecord | null>(null)
|
| 50 |
+
const [overrideReason, setOverrideReason] = useState('')
|
| 51 |
+
|
| 52 |
+
const { data: deviations } = useQuery<PagedResponse<DeviationRecord>>({
|
| 53 |
+
queryKey: ['deviations', selectedSeverity, search],
|
| 54 |
+
queryFn: () => apiClient.get('/deviations', { severity: selectedSeverity, search }),
|
| 55 |
+
refetchInterval: 30_000,
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
const { data: deviationTypes } = useQuery<PagedResponse<DeviationType>>({
|
| 59 |
+
queryKey: ['deviation-types'],
|
| 60 |
+
queryFn: () => apiClient.get('/deviation-types'),
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
const overrideMutation = useMutation({
|
| 64 |
+
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
| 65 |
+
apiClient.post(`/deviations/${id}/override`, { reason }),
|
| 66 |
+
onSuccess: () => {
|
| 67 |
+
qc.invalidateQueries({ queryKey: ['deviations'] })
|
| 68 |
+
setOverrideModal(null)
|
| 69 |
+
setOverrideReason('')
|
| 70 |
+
},
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
const stats = {
|
| 74 |
+
total: deviations?.totalCount ?? 0,
|
| 75 |
+
critical: deviations?.items.filter((d) => d.severity === 'CRITICAL').length ?? 0,
|
| 76 |
+
overridden: deviations?.items.filter((d) => d.isOverridden).length ?? 0,
|
| 77 |
+
pending: deviations?.items.filter((d) => !d.isOverridden).length ?? 0,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
return (
|
| 81 |
+
<div className="p-6 space-y-6">
|
| 82 |
+
<div className="flex items-center justify-between">
|
| 83 |
+
<div>
|
| 84 |
+
<h1 className="text-xl font-bold text-gray-900">Deviation Management</h1>
|
| 85 |
+
<p className="text-sm text-gray-500 mt-0.5">Track, analyze, and override policy deviations</p>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
{/* Stats */}
|
| 90 |
+
<div className="grid grid-cols-4 gap-4">
|
| 91 |
+
{[
|
| 92 |
+
{ label: 'Total Deviations', value: stats.total, icon: '📋', color: 'blue' },
|
| 93 |
+
{ label: 'Critical', value: stats.critical, icon: '🚨', color: 'red' },
|
| 94 |
+
{ label: 'Pending Override', value: stats.pending, icon: '⏳', color: 'orange' },
|
| 95 |
+
{ label: 'Overridden', value: stats.overridden, icon: '✅', color: 'emerald' },
|
| 96 |
+
].map(({ label, value, icon, color }) => (
|
| 97 |
+
<div key={label} className="bg-white rounded-xl border border-gray-200 p-4">
|
| 98 |
+
<div className="flex items-center justify-between">
|
| 99 |
+
<div>
|
| 100 |
+
<p className="text-xs text-gray-500">{label}</p>
|
| 101 |
+
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
|
| 102 |
+
</div>
|
| 103 |
+
<div className="text-2xl">{icon}</div>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
))}
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{/* Tabs */}
|
| 110 |
+
<div className="flex border-b border-gray-200">
|
| 111 |
+
{[
|
| 112 |
+
{ id: 'live', label: 'Live Deviations' },
|
| 113 |
+
{ id: 'types', label: 'Deviation Types' },
|
| 114 |
+
].map(({ id, label }) => (
|
| 115 |
+
<button
|
| 116 |
+
key={id}
|
| 117 |
+
onClick={() => setActiveTab(id as any)}
|
| 118 |
+
className={`py-3 px-6 text-sm font-medium border-b-2 transition-colors ${
|
| 119 |
+
activeTab === id ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
| 120 |
+
}`}
|
| 121 |
+
>
|
| 122 |
+
{label}
|
| 123 |
+
</button>
|
| 124 |
+
))}
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
{activeTab === 'live' && (
|
| 128 |
+
<div className="bg-white rounded-xl border border-gray-200">
|
| 129 |
+
{/* Filters */}
|
| 130 |
+
<div className="px-5 py-4 border-b border-gray-100 flex items-center gap-3">
|
| 131 |
+
<input
|
| 132 |
+
value={search}
|
| 133 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 134 |
+
placeholder="Search deviations..."
|
| 135 |
+
className="flex-1 text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 136 |
+
/>
|
| 137 |
+
<select
|
| 138 |
+
value={selectedSeverity}
|
| 139 |
+
onChange={(e) => setSelectedSeverity(e.target.value)}
|
| 140 |
+
className="text-sm border border-gray-200 rounded-lg px-3 py-2"
|
| 141 |
+
>
|
| 142 |
+
<option value="">All Severities</option>
|
| 143 |
+
{['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'].map((s) => (
|
| 144 |
+
<option key={s} value={s}>{s}</option>
|
| 145 |
+
))}
|
| 146 |
+
</select>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
{/* Table */}
|
| 150 |
+
<div className="overflow-auto">
|
| 151 |
+
<table className="w-full text-sm">
|
| 152 |
+
<thead>
|
| 153 |
+
<tr className="text-left text-xs font-semibold text-gray-500 uppercase border-b border-gray-100">
|
| 154 |
+
<th className="px-5 py-3">Deviation</th>
|
| 155 |
+
<th className="px-5 py-3">Severity</th>
|
| 156 |
+
<th className="px-5 py-3">Application</th>
|
| 157 |
+
<th className="px-5 py-3">Actual Value</th>
|
| 158 |
+
<th className="px-5 py-3">Reason</th>
|
| 159 |
+
<th className="px-5 py-3">Status</th>
|
| 160 |
+
<th className="px-5 py-3">Action</th>
|
| 161 |
+
</tr>
|
| 162 |
+
</thead>
|
| 163 |
+
<tbody className="divide-y divide-gray-50">
|
| 164 |
+
{deviations?.items.map((d) => {
|
| 165 |
+
const sc = SEVERITY_CONFIG[d.severity]
|
| 166 |
+
return (
|
| 167 |
+
<tr key={d.id} className={`hover:bg-gray-50 ${d.severity === 'CRITICAL' ? 'bg-red-50/30' : ''}`}>
|
| 168 |
+
<td className="px-5 py-3">
|
| 169 |
+
<div className="font-medium text-gray-800">{d.deviationName}</div>
|
| 170 |
+
<div className="text-xs text-gray-400 font-mono">{d.deviationCode}</div>
|
| 171 |
+
</td>
|
| 172 |
+
<td className="px-5 py-3">
|
| 173 |
+
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ${sc.bg} ${sc.text} border ${sc.border}`}>
|
| 174 |
+
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
| 175 |
+
{d.severity}
|
| 176 |
+
</span>
|
| 177 |
+
</td>
|
| 178 |
+
<td className="px-5 py-3 font-mono text-xs text-gray-600">{d.applicationId ?? '—'}</td>
|
| 179 |
+
<td className="px-5 py-3">
|
| 180 |
+
{d.fieldPath && (
|
| 181 |
+
<div>
|
| 182 |
+
<div className="text-xs text-gray-400">{d.fieldPath}</div>
|
| 183 |
+
<div className="font-mono text-red-600 font-medium">{d.actualValue ?? '—'}</div>
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
+
</td>
|
| 187 |
+
<td className="px-5 py-3 text-xs text-gray-600 max-w-48 truncate">{d.reason}</td>
|
| 188 |
+
<td className="px-5 py-3">
|
| 189 |
+
{d.isOverridden ? (
|
| 190 |
+
<span className="text-xs bg-emerald-100 text-emerald-700 px-2 py-1 rounded-full font-medium">Overridden</span>
|
| 191 |
+
) : (
|
| 192 |
+
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-full font-medium">Pending</span>
|
| 193 |
+
)}
|
| 194 |
+
</td>
|
| 195 |
+
<td className="px-5 py-3">
|
| 196 |
+
{!d.isOverridden && (
|
| 197 |
+
<button
|
| 198 |
+
onClick={() => setOverrideModal(d)}
|
| 199 |
+
className="text-xs text-blue-600 hover:text-blue-700 font-medium"
|
| 200 |
+
>
|
| 201 |
+
Override
|
| 202 |
+
</button>
|
| 203 |
+
)}
|
| 204 |
+
</td>
|
| 205 |
+
</tr>
|
| 206 |
+
)
|
| 207 |
+
})}
|
| 208 |
+
</tbody>
|
| 209 |
+
</table>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
)}
|
| 213 |
+
|
| 214 |
+
{activeTab === 'types' && (
|
| 215 |
+
<div className="grid grid-cols-2 gap-4">
|
| 216 |
+
{deviationTypes?.items.map((dt) => {
|
| 217 |
+
const sc = SEVERITY_CONFIG[dt.defaultSeverity as keyof typeof SEVERITY_CONFIG] ?? SEVERITY_CONFIG.MEDIUM
|
| 218 |
+
return (
|
| 219 |
+
<div key={dt.id} className={`rounded-xl border-2 p-4 ${sc.border} ${sc.bg}`}>
|
| 220 |
+
<div className="flex items-center justify-between mb-2">
|
| 221 |
+
<div className="font-semibold text-gray-800">{dt.deviationName}</div>
|
| 222 |
+
<span className={`text-xs px-2 py-0.5 rounded-full font-semibold ${sc.text} bg-white`}>
|
| 223 |
+
{dt.defaultSeverity}
|
| 224 |
+
</span>
|
| 225 |
+
</div>
|
| 226 |
+
<div className="text-xs font-mono text-gray-500 mb-2">{dt.deviationCode}</div>
|
| 227 |
+
<div className="text-sm text-gray-600 mb-3">{dt.description}</div>
|
| 228 |
+
{dt.recommendedAction && (
|
| 229 |
+
<div className="flex items-start gap-1.5 text-xs text-gray-500">
|
| 230 |
+
<span>💡</span>
|
| 231 |
+
<span>{dt.recommendedAction}</span>
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
{dt.requiresApproval && (
|
| 235 |
+
<div className="mt-2 text-xs text-amber-600 font-medium">⚠ Requires approval</div>
|
| 236 |
+
)}
|
| 237 |
+
</div>
|
| 238 |
+
)
|
| 239 |
+
})}
|
| 240 |
+
</div>
|
| 241 |
+
)}
|
| 242 |
+
|
| 243 |
+
{/* Override Modal */}
|
| 244 |
+
{overrideModal && (
|
| 245 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 246 |
+
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
|
| 247 |
+
<div className="px-6 py-5 border-b border-gray-100">
|
| 248 |
+
<h2 className="text-lg font-bold text-gray-900">Override Deviation</h2>
|
| 249 |
+
<p className="text-sm text-gray-500 mt-1">{overrideModal.deviationName}</p>
|
| 250 |
+
</div>
|
| 251 |
+
<div className="px-6 py-5 space-y-4">
|
| 252 |
+
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-800">
|
| 253 |
+
<p className="font-semibold mb-1">⚠ Override Reason Required</p>
|
| 254 |
+
<p>You must provide a valid business justification for overriding this deviation. This action will be logged in the audit trail.</p>
|
| 255 |
+
</div>
|
| 256 |
+
<div>
|
| 257 |
+
<label className="text-sm font-medium text-gray-700 block mb-1">Business Justification</label>
|
| 258 |
+
<textarea
|
| 259 |
+
value={overrideReason}
|
| 260 |
+
onChange={(e) => setOverrideReason(e.target.value)}
|
| 261 |
+
rows={4}
|
| 262 |
+
placeholder="Enter detailed reason for overriding this deviation..."
|
| 263 |
+
className="w-full text-sm border border-gray-200 rounded-xl px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
| 264 |
+
/>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
<div className="px-6 py-4 border-t border-gray-100 flex gap-3">
|
| 268 |
+
<button
|
| 269 |
+
onClick={() => { setOverrideModal(null); setOverrideReason('') }}
|
| 270 |
+
className="flex-1 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50"
|
| 271 |
+
>
|
| 272 |
+
Cancel
|
| 273 |
+
</button>
|
| 274 |
+
<button
|
| 275 |
+
onClick={() => overrideMutation.mutate({ id: overrideModal.id, reason: overrideReason })}
|
| 276 |
+
disabled={!overrideReason.trim() || overrideMutation.isPending}
|
| 277 |
+
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-semibold hover:bg-blue-700 disabled:opacity-50"
|
| 278 |
+
>
|
| 279 |
+
{overrideMutation.isPending ? 'Saving...' : 'Confirm Override'}
|
| 280 |
+
</button>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
)}
|
| 285 |
+
</div>
|
| 286 |
+
)
|
| 287 |
+
}
|
Dockerfile.backend
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================================
|
| 2 |
+
# OPTIM AI BRE Engine — Backend Dockerfile
|
| 3 |
+
# .NET 8 multi-stage production build
|
| 4 |
+
# ==============================================================
|
| 5 |
+
|
| 6 |
+
ARG PROJECT=OptimAI.BRE.Gateway
|
| 7 |
+
|
| 8 |
+
# ---- STAGE 1: RESTORE ----------------------------------------
|
| 9 |
+
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore
|
| 10 |
+
ARG PROJECT
|
| 11 |
+
WORKDIR /src
|
| 12 |
+
|
| 13 |
+
COPY ["OptimAI.BRE.Shared/OptimAI.BRE.Shared.csproj", "OptimAI.BRE.Shared/"]
|
| 14 |
+
COPY ["OptimAI.BRE.RuleEngine/OptimAI.BRE.RuleEngine.csproj", "OptimAI.BRE.RuleEngine/"]
|
| 15 |
+
COPY ["OptimAI.BRE.RuleDesigner/OptimAI.BRE.RuleDesigner.csproj","OptimAI.BRE.RuleDesigner/"]
|
| 16 |
+
COPY ["OptimAI.BRE.AIEngine/OptimAI.BRE.AIEngine.csproj", "OptimAI.BRE.AIEngine/"]
|
| 17 |
+
COPY ["OptimAI.BRE.ClientMgmt/OptimAI.BRE.ClientMgmt.csproj", "OptimAI.BRE.ClientMgmt/"]
|
| 18 |
+
COPY ["OptimAI.BRE.AuditService/OptimAI.BRE.AuditService.csproj","OptimAI.BRE.AuditService/"]
|
| 19 |
+
COPY ["OptimAI.BRE.ReportService/OptimAI.BRE.ReportService.csproj","OptimAI.BRE.ReportService/"]
|
| 20 |
+
COPY ["OptimAI.BRE.IdentityService/OptimAI.BRE.IdentityService.csproj","OptimAI.BRE.IdentityService/"]
|
| 21 |
+
COPY ["OptimAI.BRE.Gateway/OptimAI.BRE.Gateway.csproj", "OptimAI.BRE.Gateway/"]
|
| 22 |
+
COPY ["OptimAI.BRE.sln", "./"]
|
| 23 |
+
|
| 24 |
+
RUN dotnet restore "OptimAI.BRE.Gateway/OptimAI.BRE.Gateway.csproj" \
|
| 25 |
+
--runtime linux-x64
|
| 26 |
+
|
| 27 |
+
# ---- STAGE 2: BUILD ------------------------------------------
|
| 28 |
+
FROM restore AS build
|
| 29 |
+
ARG PROJECT
|
| 30 |
+
COPY . .
|
| 31 |
+
|
| 32 |
+
RUN dotnet build "${PROJECT}/${PROJECT}.csproj" \
|
| 33 |
+
-c Release \
|
| 34 |
+
--no-restore \
|
| 35 |
+
--runtime linux-x64 \
|
| 36 |
+
--self-contained false \
|
| 37 |
+
-o /app/build
|
| 38 |
+
|
| 39 |
+
# ---- STAGE 3: PUBLISH ----------------------------------------
|
| 40 |
+
FROM build AS publish
|
| 41 |
+
ARG PROJECT
|
| 42 |
+
|
| 43 |
+
RUN dotnet publish "${PROJECT}/${PROJECT}.csproj" \
|
| 44 |
+
-c Release \
|
| 45 |
+
--no-restore \
|
| 46 |
+
--runtime linux-x64 \
|
| 47 |
+
--self-contained false \
|
| 48 |
+
/p:UseAppHost=false \
|
| 49 |
+
-o /app/publish
|
| 50 |
+
|
| 51 |
+
# ---- STAGE 4: RUNTIME ----------------------------------------
|
| 52 |
+
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
|
| 53 |
+
|
| 54 |
+
# Install curl + tz data (for healthcheck and IST timezone)
|
| 55 |
+
RUN apk add --no-cache curl tzdata && \
|
| 56 |
+
cp /usr/share/zoneinfo/Asia/Kolkata /etc/localtime && \
|
| 57 |
+
echo "Asia/Kolkata" > /etc/timezone && \
|
| 58 |
+
apk del tzdata
|
| 59 |
+
|
| 60 |
+
# Non-root security
|
| 61 |
+
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
| 62 |
+
|
| 63 |
+
WORKDIR /app
|
| 64 |
+
|
| 65 |
+
COPY --from=publish --chown=appuser:appgroup /app/publish .
|
| 66 |
+
|
| 67 |
+
# Persistent log directory
|
| 68 |
+
RUN mkdir -p /app/logs /app/reports && \
|
| 69 |
+
chown -R appuser:appgroup /app/logs /app/reports
|
| 70 |
+
|
| 71 |
+
USER appuser
|
| 72 |
+
|
| 73 |
+
ENV ASPNETCORE_URLS=http://+:8080 \
|
| 74 |
+
ASPNETCORE_ENVIRONMENT=Production \
|
| 75 |
+
DOTNET_RUNNING_IN_CONTAINER=true \
|
| 76 |
+
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \
|
| 77 |
+
TZ=Asia/Kolkata
|
| 78 |
+
|
| 79 |
+
EXPOSE 8080
|
| 80 |
+
|
| 81 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=5 \
|
| 82 |
+
CMD curl -f http://localhost:8080/health || exit 1
|
| 83 |
+
|
| 84 |
+
ENTRYPOINT ["dotnet", "OptimAI.BRE.Gateway.dll"]
|
Dockerfile.frontend
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================================
|
| 2 |
+
# OPTIM AI BRE Engine — Frontend Dockerfile
|
| 3 |
+
# Next.js 14 standalone multi-stage production build
|
| 4 |
+
# ==============================================================
|
| 5 |
+
|
| 6 |
+
# ---- STAGE 1: BASE -------------------------------------------
|
| 7 |
+
FROM node:20-alpine AS base
|
| 8 |
+
RUN apk add --no-cache libc6-compat
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# ---- STAGE 2: DEPENDENCIES -----------------------------------
|
| 12 |
+
FROM base AS deps
|
| 13 |
+
COPY package.json package-lock.json* ./
|
| 14 |
+
|
| 15 |
+
# Install ALL deps (including dev) — needed for build
|
| 16 |
+
RUN npm ci --ignore-scripts && \
|
| 17 |
+
npm cache clean --force
|
| 18 |
+
|
| 19 |
+
# ---- STAGE 3: BUILD ------------------------------------------
|
| 20 |
+
FROM base AS builder
|
| 21 |
+
WORKDIR /app
|
| 22 |
+
|
| 23 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 24 |
+
COPY . .
|
| 25 |
+
|
| 26 |
+
# NEXT_PUBLIC_API_URL is baked at build time.
|
| 27 |
+
# /api/v1 is a relative URL — Nginx proxies it to the backend container.
|
| 28 |
+
# This means the same Docker image works for any domain or server IP.
|
| 29 |
+
ARG NEXT_PUBLIC_API_URL=/api/v1
|
| 30 |
+
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
| 31 |
+
NEXT_TELEMETRY_DISABLED=1 \
|
| 32 |
+
NODE_ENV=production
|
| 33 |
+
|
| 34 |
+
RUN npm run build
|
| 35 |
+
|
| 36 |
+
# ---- STAGE 4: RUNNER -----------------------------------------
|
| 37 |
+
FROM node:20-alpine AS runner
|
| 38 |
+
WORKDIR /app
|
| 39 |
+
|
| 40 |
+
RUN apk add --no-cache wget
|
| 41 |
+
|
| 42 |
+
ENV NODE_ENV=production \
|
| 43 |
+
NEXT_TELEMETRY_DISABLED=1 \
|
| 44 |
+
PORT=3000 \
|
| 45 |
+
HOSTNAME="0.0.0.0"
|
| 46 |
+
|
| 47 |
+
RUN addgroup --system --gid 1001 nodejs && \
|
| 48 |
+
adduser --system --uid 1001 nextjs
|
| 49 |
+
|
| 50 |
+
# Copy Next.js standalone output
|
| 51 |
+
COPY --from=builder /app/public ./public
|
| 52 |
+
RUN mkdir -p .next && chown nextjs:nodejs .next
|
| 53 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
| 54 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
| 55 |
+
|
| 56 |
+
USER nextjs
|
| 57 |
+
|
| 58 |
+
EXPOSE 3000
|
| 59 |
+
|
| 60 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
| 61 |
+
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
| 62 |
+
|
| 63 |
+
CMD ["node", "server.js"]
|
Entities.cs
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System;
|
| 2 |
+
using System.Collections.Generic;
|
| 3 |
+
|
| 4 |
+
namespace OptimAI.BRE.Shared.Domain;
|
| 5 |
+
|
| 6 |
+
// ============================================================
|
| 7 |
+
// BASE ENTITIES
|
| 8 |
+
// ============================================================
|
| 9 |
+
|
| 10 |
+
public abstract class BaseEntity
|
| 11 |
+
{
|
| 12 |
+
public Guid Id { get; set; } = Guid.NewGuid();
|
| 13 |
+
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
| 14 |
+
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
public abstract class TenantEntity : BaseEntity
|
| 18 |
+
{
|
| 19 |
+
public Guid TenantId { get; set; }
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// ============================================================
|
| 23 |
+
// TENANT
|
| 24 |
+
// ============================================================
|
| 25 |
+
|
| 26 |
+
public class Tenant : BaseEntity
|
| 27 |
+
{
|
| 28 |
+
public string TenantCode { get; set; } = default!;
|
| 29 |
+
public string TenantName { get; set; } = default!;
|
| 30 |
+
public string? DisplayName { get; set; }
|
| 31 |
+
public string? LogoUrl { get; set; }
|
| 32 |
+
public string PrimaryColor { get; set; } = "#1E40AF";
|
| 33 |
+
public string SecondaryColor { get; set; } = "#3B82F6";
|
| 34 |
+
public PlanType PlanType { get; set; } = PlanType.Enterprise;
|
| 35 |
+
public int MaxRules { get; set; } = 1000;
|
| 36 |
+
public long MaxExecutionsPerDay { get; set; } = 1_000_000;
|
| 37 |
+
public bool IsActive { get; set; } = true;
|
| 38 |
+
public DateTime? TrialEndDate { get; set; }
|
| 39 |
+
public DateTime? SubscriptionEndDate { get; set; }
|
| 40 |
+
public Dictionary<string, object> Settings { get; set; } = new();
|
| 41 |
+
|
| 42 |
+
public ICollection<User> Users { get; set; } = new List<User>();
|
| 43 |
+
public ICollection<Rule> Rules { get; set; } = new List<Rule>();
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
public enum PlanType { Starter, Professional, Enterprise }
|
| 47 |
+
|
| 48 |
+
// ============================================================
|
| 49 |
+
// IDENTITY
|
| 50 |
+
// ============================================================
|
| 51 |
+
|
| 52 |
+
public class User : TenantEntity
|
| 53 |
+
{
|
| 54 |
+
public string Email { get; set; } = default!;
|
| 55 |
+
public string Username { get; set; } = default!;
|
| 56 |
+
public string PasswordHash { get; set; } = default!;
|
| 57 |
+
public string FullName { get; set; } = default!;
|
| 58 |
+
public string? EmployeeId { get; set; }
|
| 59 |
+
public string? Designation { get; set; }
|
| 60 |
+
public string? Department { get; set; }
|
| 61 |
+
public string? Mobile { get; set; }
|
| 62 |
+
public string? ProfileImageUrl { get; set; }
|
| 63 |
+
public bool IsActive { get; set; } = true;
|
| 64 |
+
public bool IsEmailVerified { get; set; }
|
| 65 |
+
public bool IsMfaEnabled { get; set; }
|
| 66 |
+
public string? MfaSecret { get; set; }
|
| 67 |
+
public DateTime? LastLoginAt { get; set; }
|
| 68 |
+
public string? LastLoginIp { get; set; }
|
| 69 |
+
public int FailedLoginCount { get; set; }
|
| 70 |
+
public DateTime? LockedUntil { get; set; }
|
| 71 |
+
public DateTime? PasswordChangedAt { get; set; }
|
| 72 |
+
|
| 73 |
+
public Tenant Tenant { get; set; } = default!;
|
| 74 |
+
public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
public class Role : TenantEntity
|
| 78 |
+
{
|
| 79 |
+
public string RoleCode { get; set; } = default!;
|
| 80 |
+
public string RoleName { get; set; } = default!;
|
| 81 |
+
public string? Description { get; set; }
|
| 82 |
+
public bool IsSystemRole { get; set; }
|
| 83 |
+
public bool IsActive { get; set; } = true;
|
| 84 |
+
|
| 85 |
+
public ICollection<RolePermission> RolePermissions { get; set; } = new List<RolePermission>();
|
| 86 |
+
public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
public class Permission : BaseEntity
|
| 90 |
+
{
|
| 91 |
+
public string PermissionCode { get; set; } = default!;
|
| 92 |
+
public string PermissionName { get; set; } = default!;
|
| 93 |
+
public string Module { get; set; } = default!;
|
| 94 |
+
public string? Description { get; set; }
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
public class RolePermission
|
| 98 |
+
{
|
| 99 |
+
public Guid RoleId { get; set; }
|
| 100 |
+
public Guid PermissionId { get; set; }
|
| 101 |
+
public DateTime GrantedAt { get; set; } = DateTime.UtcNow;
|
| 102 |
+
public Guid? GrantedBy { get; set; }
|
| 103 |
+
public Role Role { get; set; } = default!;
|
| 104 |
+
public Permission Permission { get; set; } = default!;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
public class UserRole
|
| 108 |
+
{
|
| 109 |
+
public Guid UserId { get; set; }
|
| 110 |
+
public Guid RoleId { get; set; }
|
| 111 |
+
public DateTime AssignedAt { get; set; } = DateTime.UtcNow;
|
| 112 |
+
public Guid? AssignedBy { get; set; }
|
| 113 |
+
public User User { get; set; } = default!;
|
| 114 |
+
public Role Role { get; set; } = default!;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
public class ApiKey : TenantEntity
|
| 118 |
+
{
|
| 119 |
+
public string KeyName { get; set; } = default!;
|
| 120 |
+
public string ApiKeyHash { get; set; } = default!;
|
| 121 |
+
public string ApiKeyPrefix { get; set; } = default!;
|
| 122 |
+
public List<string> Scopes { get; set; } = new();
|
| 123 |
+
public bool IsActive { get; set; } = true;
|
| 124 |
+
public DateTime? ExpiresAt { get; set; }
|
| 125 |
+
public DateTime? LastUsedAt { get; set; }
|
| 126 |
+
public int RateLimitPerMinute { get; set; } = 1000;
|
| 127 |
+
public Guid CreatedBy { get; set; }
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// ============================================================
|
| 131 |
+
// RULE ENGINE CORE
|
| 132 |
+
// ============================================================
|
| 133 |
+
|
| 134 |
+
public class Rule : TenantEntity
|
| 135 |
+
{
|
| 136 |
+
public string RuleCode { get; set; } = default!;
|
| 137 |
+
public string RuleName { get; set; } = default!;
|
| 138 |
+
public string? Description { get; set; }
|
| 139 |
+
public Guid? CategoryId { get; set; }
|
| 140 |
+
public RuleType RuleType { get; set; }
|
| 141 |
+
public int Priority { get; set; } = 100;
|
| 142 |
+
public bool IsActive { get; set; } = true;
|
| 143 |
+
public bool IsPublished { get; set; }
|
| 144 |
+
public bool IsDraft { get; set; } = true;
|
| 145 |
+
public RuleStatus Status { get; set; } = RuleStatus.Draft;
|
| 146 |
+
public Guid? CurrentVersionId { get; set; }
|
| 147 |
+
public List<string> Tags { get; set; } = new();
|
| 148 |
+
public Guid CreatedBy { get; set; }
|
| 149 |
+
|
| 150 |
+
public RuleCategory? Category { get; set; }
|
| 151 |
+
public RuleVersion? CurrentVersion { get; set; }
|
| 152 |
+
public ICollection<RuleVersion> Versions { get; set; } = new List<RuleVersion>();
|
| 153 |
+
public ICollection<RuleScope> Scopes { get; set; } = new List<RuleScope>();
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
public enum RuleType
|
| 157 |
+
{
|
| 158 |
+
Eligibility, Credit, Bureau, FI, Valuation,
|
| 159 |
+
Fraud, Compliance, Deviation, Income, Kyc,
|
| 160 |
+
Vehicle, Guarantor, CollateralCheck
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
public enum RuleStatus { Draft, PendingApproval, Approved, Published, Archived }
|
| 164 |
+
|
| 165 |
+
public class RuleVersion : TenantEntity
|
| 166 |
+
{
|
| 167 |
+
public Guid RuleId { get; set; }
|
| 168 |
+
public int VersionNumber { get; set; }
|
| 169 |
+
public string? VersionLabel { get; set; }
|
| 170 |
+
public RuleDefinition RuleDefinition { get; set; } = default!;
|
| 171 |
+
public string? ChangeSummary { get; set; }
|
| 172 |
+
public bool IsCurrent { get; set; }
|
| 173 |
+
public RuleStatus Status { get; set; } = RuleStatus.Draft;
|
| 174 |
+
public Guid? ApprovedBy { get; set; }
|
| 175 |
+
public DateTime? ApprovedAt { get; set; }
|
| 176 |
+
public Guid? PublishedBy { get; set; }
|
| 177 |
+
public DateTime? PublishedAt { get; set; }
|
| 178 |
+
public Guid CreatedBy { get; set; }
|
| 179 |
+
|
| 180 |
+
public Rule Rule { get; set; } = default!;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
public class RuleDefinition
|
| 184 |
+
{
|
| 185 |
+
public ConditionGroup Conditions { get; set; } = default!;
|
| 186 |
+
public List<RuleAction> Actions { get; set; } = new();
|
| 187 |
+
public RuleMetadata Metadata { get; set; } = new();
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
public class ConditionGroup
|
| 191 |
+
{
|
| 192 |
+
public string Id { get; set; } = Guid.NewGuid().ToString();
|
| 193 |
+
public LogicalOperator Operator { get; set; } = LogicalOperator.And;
|
| 194 |
+
public List<ConditionNode> Rules { get; set; } = new();
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
public class ConditionNode
|
| 198 |
+
{
|
| 199 |
+
public string Id { get; set; } = Guid.NewGuid().ToString();
|
| 200 |
+
public bool IsGroup { get; set; }
|
| 201 |
+
// When IsGroup = false (leaf condition)
|
| 202 |
+
public string? Field { get; set; }
|
| 203 |
+
public ComparisonOperator? Operator { get; set; }
|
| 204 |
+
public object? Value { get; set; }
|
| 205 |
+
public string? Value2 { get; set; } // for BETWEEN
|
| 206 |
+
public ValueType ValueType { get; set; } = ValueType.Literal;
|
| 207 |
+
public string? ReferenceField { get; set; } // for FIELD_COMPARE
|
| 208 |
+
public string? CustomFunction { get; set; }
|
| 209 |
+
// When IsGroup = true
|
| 210 |
+
public ConditionGroup? Group { get; set; }
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
public enum LogicalOperator { And, Or, Not }
|
| 214 |
+
|
| 215 |
+
public enum ComparisonOperator
|
| 216 |
+
{
|
| 217 |
+
Equals, NotEquals,
|
| 218 |
+
GreaterThan, GreaterThanOrEqual,
|
| 219 |
+
LessThan, LessThanOrEqual,
|
| 220 |
+
Between, NotBetween,
|
| 221 |
+
In, NotIn,
|
| 222 |
+
Contains, NotContains,
|
| 223 |
+
StartsWith, EndsWith,
|
| 224 |
+
IsNull, IsNotNull,
|
| 225 |
+
IsTrue, IsFalse,
|
| 226 |
+
Regex, FieldCompare
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
public enum ValueType { Literal, Field, Function, Expression }
|
| 230 |
+
|
| 231 |
+
public class RuleAction
|
| 232 |
+
{
|
| 233 |
+
public string Id { get; set; } = Guid.NewGuid().ToString();
|
| 234 |
+
public ActionType Type { get; set; }
|
| 235 |
+
public string? Value { get; set; }
|
| 236 |
+
public string? Field { get; set; }
|
| 237 |
+
public Dictionary<string, string> Parameters { get; set; } = new();
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
public enum ActionType
|
| 241 |
+
{
|
| 242 |
+
SetDecision,
|
| 243 |
+
SetRisk,
|
| 244 |
+
SetTrafficLight,
|
| 245 |
+
AddDeviation,
|
| 246 |
+
SetField,
|
| 247 |
+
SendNotification,
|
| 248 |
+
TriggerWorkflow,
|
| 249 |
+
AddTag,
|
| 250 |
+
SetScore
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
public class RuleMetadata
|
| 254 |
+
{
|
| 255 |
+
public int ExecutionOrder { get; set; } = 0;
|
| 256 |
+
public bool StopOnMatch { get; set; } = false;
|
| 257 |
+
public ErrorHandling ErrorHandling { get; set; } = ErrorHandling.Skip;
|
| 258 |
+
public string? Notes { get; set; }
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
public enum ErrorHandling { Skip, Fail, UseDefault }
|
| 262 |
+
|
| 263 |
+
public class RuleScope : TenantEntity
|
| 264 |
+
{
|
| 265 |
+
public Guid RuleId { get; set; }
|
| 266 |
+
public ScopeType ScopeType { get; set; }
|
| 267 |
+
public string ScopeValue { get; set; } = default!;
|
| 268 |
+
public bool IsExcluded { get; set; }
|
| 269 |
+
public Rule Rule { get; set; } = default!;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
public enum ScopeType { Product, Branch, Stage, UserRole, Global }
|
| 273 |
+
|
| 274 |
+
public class RuleCategory : TenantEntity
|
| 275 |
+
{
|
| 276 |
+
public string CategoryCode { get; set; } = default!;
|
| 277 |
+
public string CategoryName { get; set; } = default!;
|
| 278 |
+
public string? Description { get; set; }
|
| 279 |
+
public string? Icon { get; set; }
|
| 280 |
+
public int SortOrder { get; set; }
|
| 281 |
+
public bool IsActive { get; set; } = true;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
public class RuleSet : TenantEntity
|
| 285 |
+
{
|
| 286 |
+
public string SetCode { get; set; } = default!;
|
| 287 |
+
public string SetName { get; set; } = default!;
|
| 288 |
+
public string? Description { get; set; }
|
| 289 |
+
public ExecutionMode ExecutionMode { get; set; } = ExecutionMode.All;
|
| 290 |
+
public bool IsActive { get; set; } = true;
|
| 291 |
+
public Guid CreatedBy { get; set; }
|
| 292 |
+
|
| 293 |
+
public ICollection<RuleSetMember> Members { get; set; } = new List<RuleSetMember>();
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
public enum ExecutionMode { All, FirstMatch, Scored }
|
| 297 |
+
|
| 298 |
+
public class RuleSetMember
|
| 299 |
+
{
|
| 300 |
+
public Guid SetId { get; set; }
|
| 301 |
+
public Guid RuleId { get; set; }
|
| 302 |
+
public int SortOrder { get; set; }
|
| 303 |
+
public decimal Weight { get; set; } = 1.0m;
|
| 304 |
+
public RuleSet RuleSet { get; set; } = default!;
|
| 305 |
+
public Rule Rule { get; set; } = default!;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
// ============================================================
|
| 309 |
+
// EXECUTION
|
| 310 |
+
// ============================================================
|
| 311 |
+
|
| 312 |
+
public class ExecutionRequest : TenantEntity
|
| 313 |
+
{
|
| 314 |
+
public string? CorrelationId { get; set; }
|
| 315 |
+
public string? ApplicationId { get; set; }
|
| 316 |
+
public string? ProductCode { get; set; }
|
| 317 |
+
public string? BranchCode { get; set; }
|
| 318 |
+
public string? StageCode { get; set; }
|
| 319 |
+
public Guid? RuleSetId { get; set; }
|
| 320 |
+
public Dictionary<string, object> InputPayload { get; set; } = new();
|
| 321 |
+
public string? InputHash { get; set; }
|
| 322 |
+
public ExecutionStatus Status { get; set; } = ExecutionStatus.Pending;
|
| 323 |
+
public int Priority { get; set; } = 5;
|
| 324 |
+
public string? SourceSystem { get; set; }
|
| 325 |
+
public Guid? ApiKeyId { get; set; }
|
| 326 |
+
public DateTime? ProcessingStartedAt { get; set; }
|
| 327 |
+
public DateTime? CompletedAt { get; set; }
|
| 328 |
+
public int? ProcessingMs { get; set; }
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
public enum ExecutionStatus { Pending, Processing, Completed, Failed }
|
| 332 |
+
|
| 333 |
+
public class ExecutionResult : TenantEntity
|
| 334 |
+
{
|
| 335 |
+
public Guid RequestId { get; set; }
|
| 336 |
+
public Decision FinalDecision { get; set; }
|
| 337 |
+
public decimal? RiskScore { get; set; }
|
| 338 |
+
public RiskCategory? RiskCategory { get; set; }
|
| 339 |
+
public TrafficLight? TrafficLight { get; set; }
|
| 340 |
+
public int TotalRulesEvaluated { get; set; }
|
| 341 |
+
public int RulesPassed { get; set; }
|
| 342 |
+
public int RulesFailed { get; set; }
|
| 343 |
+
public int RulesSkipped { get; set; }
|
| 344 |
+
public int DeviationsCount { get; set; }
|
| 345 |
+
public int? ExecutionMs { get; set; }
|
| 346 |
+
public List<RuleExecutionSummary> RuleResults { get; set; } = new();
|
| 347 |
+
public Dictionary<string, object> FieldValues { get; set; } = new();
|
| 348 |
+
public string? AiSummary { get; set; }
|
| 349 |
+
public AiAnalysis? AiAnalysis { get; set; }
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
public enum Decision { Approve, Reject, Deviation, Refer, Pending }
|
| 353 |
+
public enum RiskCategory { Low, Medium, High, Critical }
|
| 354 |
+
public enum TrafficLight { Green, Amber, Red }
|
| 355 |
+
|
| 356 |
+
public class RuleExecutionSummary
|
| 357 |
+
{
|
| 358 |
+
public Guid RuleId { get; set; }
|
| 359 |
+
public string RuleCode { get; set; } = default!;
|
| 360 |
+
public string RuleName { get; set; } = default!;
|
| 361 |
+
public int VersionNumber { get; set; }
|
| 362 |
+
public bool IsMatched { get; set; }
|
| 363 |
+
public List<ConditionEvaluationResult> ConditionsEvaluated { get; set; } = new();
|
| 364 |
+
public List<string> ActionsExecuted { get; set; } = new();
|
| 365 |
+
public int? ExecutionMs { get; set; }
|
| 366 |
+
public string? ErrorMessage { get; set; }
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
public class ConditionEvaluationResult
|
| 370 |
+
{
|
| 371 |
+
public string ConditionId { get; set; } = default!;
|
| 372 |
+
public string Field { get; set; } = default!;
|
| 373 |
+
public string Operator { get; set; } = default!;
|
| 374 |
+
public object? ExpectedValue { get; set; }
|
| 375 |
+
public object? ActualValue { get; set; }
|
| 376 |
+
public bool Result { get; set; }
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
public class AiAnalysis
|
| 380 |
+
{
|
| 381 |
+
public string RiskSummary { get; set; } = default!;
|
| 382 |
+
public string CreditSummary { get; set; } = default!;
|
| 383 |
+
public List<string> Strengths { get; set; } = new();
|
| 384 |
+
public List<string> Weaknesses { get; set; } = new();
|
| 385 |
+
public string DeviationsSummary { get; set; } = default!;
|
| 386 |
+
public string ApprovalRecommendation { get; set; } = default!;
|
| 387 |
+
public List<string> RejectionReasons { get; set; } = new();
|
| 388 |
+
public List<string> AdditionalDocuments { get; set; } = new();
|
| 389 |
+
public string UnderwritingNotes { get; set; } = default!;
|
| 390 |
+
public double ConfidenceScore { get; set; }
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
// ============================================================
|
| 394 |
+
// DEVIATIONS
|
| 395 |
+
// ============================================================
|
| 396 |
+
|
| 397 |
+
public class DeviationType : TenantEntity
|
| 398 |
+
{
|
| 399 |
+
public string DeviationCode { get; set; } = default!;
|
| 400 |
+
public string DeviationName { get; set; } = default!;
|
| 401 |
+
public string? Category { get; set; }
|
| 402 |
+
public Severity DefaultSeverity { get; set; } = Severity.Medium;
|
| 403 |
+
public string? Description { get; set; }
|
| 404 |
+
public string? RecommendedAction { get; set; }
|
| 405 |
+
public bool RequiresApproval { get; set; }
|
| 406 |
+
public string? ApproverRole { get; set; }
|
| 407 |
+
public bool IsActive { get; set; } = true;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
public enum Severity { Low, Medium, High, Critical }
|
| 411 |
+
|
| 412 |
+
public class ExecutionDeviation
|
| 413 |
+
{
|
| 414 |
+
public Guid Id { get; set; } = Guid.NewGuid();
|
| 415 |
+
public Guid ResultId { get; set; }
|
| 416 |
+
public Guid? RuleId { get; set; }
|
| 417 |
+
public Guid? DeviationTypeId { get; set; }
|
| 418 |
+
public string DeviationCode { get; set; } = default!;
|
| 419 |
+
public string DeviationName { get; set; } = default!;
|
| 420 |
+
public Severity Severity { get; set; }
|
| 421 |
+
public string Reason { get; set; } = default!;
|
| 422 |
+
public string? FieldPath { get; set; }
|
| 423 |
+
public string? ActualValue { get; set; }
|
| 424 |
+
public string? ExpectedValue { get; set; }
|
| 425 |
+
public string? RecommendedAction { get; set; }
|
| 426 |
+
public bool IsOverridden { get; set; }
|
| 427 |
+
public Guid? OverriddenBy { get; set; }
|
| 428 |
+
public string? OverrideReason { get; set; }
|
| 429 |
+
public DateTime? OverrideAt { get; set; }
|
| 430 |
+
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
| 431 |
+
public Guid TenantId { get; set; }
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
// ============================================================
|
| 435 |
+
// AUDIT
|
| 436 |
+
// ============================================================
|
| 437 |
+
|
| 438 |
+
public class AuditLog
|
| 439 |
+
{
|
| 440 |
+
public Guid Id { get; set; } = Guid.NewGuid();
|
| 441 |
+
public Guid TenantId { get; set; }
|
| 442 |
+
public Guid? UserId { get; set; }
|
| 443 |
+
public string Action { get; set; } = default!;
|
| 444 |
+
public string EntityType { get; set; } = default!;
|
| 445 |
+
public string? EntityId { get; set; }
|
| 446 |
+
public Dictionary<string, object>? OldValues { get; set; }
|
| 447 |
+
public Dictionary<string, object>? NewValues { get; set; }
|
| 448 |
+
public string? IpAddress { get; set; }
|
| 449 |
+
public string? UserAgent { get; set; }
|
| 450 |
+
public string? RequestId { get; set; }
|
| 451 |
+
public bool IsSuccess { get; set; } = true;
|
| 452 |
+
public string? ErrorMessage { get; set; }
|
| 453 |
+
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
| 454 |
+
}
|
IRuleExecutionService.cs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using OptimAI.BRE.Shared.Domain;
|
| 2 |
+
using System.Text.Json.Nodes;
|
| 3 |
+
|
| 4 |
+
namespace OptimAI.BRE.RuleEngine.Domain;
|
| 5 |
+
|
| 6 |
+
public interface IRuleExecutionService
|
| 7 |
+
{
|
| 8 |
+
Task<ExecutionResult> ExecuteAsync(ExecutionContext context, CancellationToken ct = default);
|
| 9 |
+
Task<RuleEvaluationResult> EvaluateRuleAsync(Rule rule, ExecutionContext context, CancellationToken ct = default);
|
| 10 |
+
Task<bool> EvaluateConditionGroupAsync(ConditionGroup group, DynamicDataContext data, CancellationToken ct = default);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
public interface IRuleLoader
|
| 14 |
+
{
|
| 15 |
+
Task<IReadOnlyList<Rule>> LoadRulesAsync(RuleLoadRequest request, CancellationToken ct = default);
|
| 16 |
+
Task<Rule?> LoadRuleByCodeAsync(Guid tenantId, string ruleCode, CancellationToken ct = default);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
public interface IDynamicFieldResolver
|
| 20 |
+
{
|
| 21 |
+
object? Resolve(string fieldPath, DynamicDataContext context);
|
| 22 |
+
bool TryResolve(string fieldPath, DynamicDataContext context, out object? value);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
public interface IConditionEvaluator
|
| 26 |
+
{
|
| 27 |
+
bool Evaluate(ConditionNode condition, DynamicDataContext context);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
public interface IActionExecutor
|
| 31 |
+
{
|
| 32 |
+
Task ExecuteAsync(RuleAction action, ExecutionContext context, RuleExecutionState state);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
public interface IRiskScoringEngine
|
| 36 |
+
{
|
| 37 |
+
Task<RiskScoreResult> CalculateAsync(ExecutionContext context, List<RuleEvaluationResult> results);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// ============================================================
|
| 41 |
+
// EXECUTION CONTEXT & STATE
|
| 42 |
+
// ============================================================
|
| 43 |
+
|
| 44 |
+
public class ExecutionContext
|
| 45 |
+
{
|
| 46 |
+
public Guid TenantId { get; set; }
|
| 47 |
+
public string? CorrelationId { get; set; }
|
| 48 |
+
public string? ApplicationId { get; set; }
|
| 49 |
+
public string? ProductCode { get; set; }
|
| 50 |
+
public string? BranchCode { get; set; }
|
| 51 |
+
public string? StageCode { get; set; }
|
| 52 |
+
public string? SourceSystem { get; set; }
|
| 53 |
+
public Guid? RuleSetId { get; set; }
|
| 54 |
+
public DynamicDataContext Data { get; set; } = default!;
|
| 55 |
+
public ExecutionOptions Options { get; set; } = new();
|
| 56 |
+
public Guid? RequestedBy { get; set; }
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
public class ExecutionOptions
|
| 60 |
+
{
|
| 61 |
+
public bool EnableAiAnalysis { get; set; } = true;
|
| 62 |
+
public bool EnableRiskScoring { get; set; } = true;
|
| 63 |
+
public bool StopOnFirstReject { get; set; } = false;
|
| 64 |
+
public int TimeoutMs { get; set; } = 5000;
|
| 65 |
+
public bool IncludeConditionDetails { get; set; } = true;
|
| 66 |
+
public bool EnableCaching { get; set; } = true;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
public class DynamicDataContext
|
| 70 |
+
{
|
| 71 |
+
private readonly JsonObject _root;
|
| 72 |
+
|
| 73 |
+
public DynamicDataContext(Dictionary<string, object> data)
|
| 74 |
+
{
|
| 75 |
+
_root = ConvertToJsonObject(data);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
public DynamicDataContext(JsonObject root)
|
| 79 |
+
{
|
| 80 |
+
_root = root;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
public object? GetValue(string path)
|
| 84 |
+
{
|
| 85 |
+
var parts = path.Split('.');
|
| 86 |
+
JsonNode? current = _root;
|
| 87 |
+
|
| 88 |
+
foreach (var part in parts)
|
| 89 |
+
{
|
| 90 |
+
if (current is JsonObject obj)
|
| 91 |
+
{
|
| 92 |
+
if (!obj.TryGetPropertyValue(part, out current) || current == null)
|
| 93 |
+
return null;
|
| 94 |
+
}
|
| 95 |
+
else if (current is JsonArray arr && int.TryParse(part, out var idx))
|
| 96 |
+
{
|
| 97 |
+
current = idx < arr.Count ? arr[idx] : null;
|
| 98 |
+
}
|
| 99 |
+
else
|
| 100 |
+
{
|
| 101 |
+
return null;
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
return ExtractValue(current);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
public bool SetValue(string path, object value)
|
| 109 |
+
{
|
| 110 |
+
var parts = path.Split('.');
|
| 111 |
+
JsonObject? current = _root;
|
| 112 |
+
|
| 113 |
+
for (int i = 0; i < parts.Length - 1; i++)
|
| 114 |
+
{
|
| 115 |
+
if (current == null) return false;
|
| 116 |
+
if (!current.TryGetPropertyValue(parts[i], out var next) || next is not JsonObject nextObj)
|
| 117 |
+
{
|
| 118 |
+
nextObj = new JsonObject();
|
| 119 |
+
current[parts[i]] = nextObj;
|
| 120 |
+
}
|
| 121 |
+
current = nextObj as JsonObject;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
if (current == null) return false;
|
| 125 |
+
current[parts[^1]] = JsonValue.Create(value);
|
| 126 |
+
return true;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
public Dictionary<string, object> ToDictionary()
|
| 130 |
+
{
|
| 131 |
+
var result = new Dictionary<string, object>();
|
| 132 |
+
FlattenJsonObject(_root, "", result);
|
| 133 |
+
return result;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
private static object? ExtractValue(JsonNode? node) => node switch
|
| 137 |
+
{
|
| 138 |
+
JsonValue v when v.TryGetValue<decimal>(out var d) => d,
|
| 139 |
+
JsonValue v when v.TryGetValue<bool>(out var b) => b,
|
| 140 |
+
JsonValue v when v.TryGetValue<string>(out var s) => s,
|
| 141 |
+
JsonValue v when v.TryGetValue<DateTime>(out var dt) => dt,
|
| 142 |
+
JsonArray arr => arr.Select(ExtractValue).ToList(),
|
| 143 |
+
JsonObject obj => obj.ToDictionary(kv => kv.Key, kv => ExtractValue(kv.Value)!),
|
| 144 |
+
null => null,
|
| 145 |
+
_ => node.ToString()
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
private static JsonObject ConvertToJsonObject(Dictionary<string, object> data)
|
| 149 |
+
{
|
| 150 |
+
var json = System.Text.Json.JsonSerializer.Serialize(data);
|
| 151 |
+
return JsonNode.Parse(json) as JsonObject ?? new JsonObject();
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
private static void FlattenJsonObject(JsonObject obj, string prefix, Dictionary<string, object> result)
|
| 155 |
+
{
|
| 156 |
+
foreach (var kv in obj)
|
| 157 |
+
{
|
| 158 |
+
var key = string.IsNullOrEmpty(prefix) ? kv.Key : $"{prefix}.{kv.Key}";
|
| 159 |
+
if (kv.Value is JsonObject nested)
|
| 160 |
+
FlattenJsonObject(nested, key, result);
|
| 161 |
+
else if (kv.Value != null)
|
| 162 |
+
result[key] = ExtractValue(kv.Value)!;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
public class ExecutionState
|
| 168 |
+
{
|
| 169 |
+
public Decision CurrentDecision { get; set; } = Decision.Pending;
|
| 170 |
+
public decimal RiskScore { get; set; } = 50m;
|
| 171 |
+
public TrafficLight TrafficLight { get; set; } = TrafficLight.Amber;
|
| 172 |
+
public List<ExecutionDeviation> Deviations { get; set; } = new();
|
| 173 |
+
public List<string> Tags { get; set; } = new();
|
| 174 |
+
public Dictionary<string, object> OutputFields { get; set; } = new();
|
| 175 |
+
public bool ShouldStop { get; set; }
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
public class RuleExecutionState
|
| 179 |
+
{
|
| 180 |
+
public ExecutionState GlobalState { get; set; } = default!;
|
| 181 |
+
public DynamicDataContext Data { get; set; } = default!;
|
| 182 |
+
public Guid TenantId { get; set; }
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
public class RuleEvaluationResult
|
| 186 |
+
{
|
| 187 |
+
public Guid RuleId { get; set; }
|
| 188 |
+
public string RuleCode { get; set; } = default!;
|
| 189 |
+
public string RuleName { get; set; } = default!;
|
| 190 |
+
public int VersionNumber { get; set; }
|
| 191 |
+
public int ExecutionOrder { get; set; }
|
| 192 |
+
public bool IsMatched { get; set; }
|
| 193 |
+
public bool HasError { get; set; }
|
| 194 |
+
public string? ErrorMessage { get; set; }
|
| 195 |
+
public List<ConditionEvaluationResult> ConditionResults { get; set; } = new();
|
| 196 |
+
public List<string> ExecutedActions { get; set; } = new();
|
| 197 |
+
public int ExecutionMs { get; set; }
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
public class RuleLoadRequest
|
| 201 |
+
{
|
| 202 |
+
public Guid TenantId { get; set; }
|
| 203 |
+
public string? ProductCode { get; set; }
|
| 204 |
+
public string? BranchCode { get; set; }
|
| 205 |
+
public string? StageCode { get; set; }
|
| 206 |
+
public Guid? RuleSetId { get; set; }
|
| 207 |
+
public List<RuleType> RuleTypes { get; set; } = new();
|
| 208 |
+
public bool PublishedOnly { get; set; } = true;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
public class RiskScoreResult
|
| 212 |
+
{
|
| 213 |
+
public decimal Score { get; set; }
|
| 214 |
+
public RiskCategory Category { get; set; }
|
| 215 |
+
public TrafficLight TrafficLight { get; set; }
|
| 216 |
+
public Dictionary<string, decimal> ComponentScores { get; set; } = new();
|
| 217 |
+
}
|
OptimAI.BRE.AuditService.csproj
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
<PropertyGroup>
|
| 3 |
+
<TargetFramework>net8.0</TargetFramework>
|
| 4 |
+
<Nullable>enable</Nullable>
|
| 5 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 6 |
+
<RootNamespace>OptimAI.BRE.AuditService</RootNamespace>
|
| 7 |
+
</PropertyGroup>
|
| 8 |
+
<ItemGroup>
|
| 9 |
+
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
| 10 |
+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
| 11 |
+
</ItemGroup>
|
| 12 |
+
<ItemGroup>
|
| 13 |
+
<ProjectReference Include="..\OptimAI.BRE.Shared\OptimAI.BRE.Shared.csproj" />
|
| 14 |
+
<ProjectReference Include="..\OptimAI.BRE.RuleEngine\OptimAI.BRE.RuleEngine.csproj" />
|
| 15 |
+
</ItemGroup>
|
| 16 |
+
</Project>
|
OptimAI.BRE.ClientMgmt.csproj
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
<PropertyGroup>
|
| 3 |
+
<TargetFramework>net8.0</TargetFramework>
|
| 4 |
+
<Nullable>enable</Nullable>
|
| 5 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 6 |
+
<RootNamespace>OptimAI.BRE.ClientMgmt</RootNamespace>
|
| 7 |
+
</PropertyGroup>
|
| 8 |
+
<ItemGroup>
|
| 9 |
+
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
| 10 |
+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
| 11 |
+
</ItemGroup>
|
| 12 |
+
<ItemGroup>
|
| 13 |
+
<ProjectReference Include="..\OptimAI.BRE.Shared\OptimAI.BRE.Shared.csproj" />
|
| 14 |
+
<ProjectReference Include="..\OptimAI.BRE.RuleEngine\OptimAI.BRE.RuleEngine.csproj" />
|
| 15 |
+
</ItemGroup>
|
| 16 |
+
</Project>
|
OptimAI.BRE.Gateway.csproj
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk.Web">
|
| 2 |
+
<PropertyGroup>
|
| 3 |
+
<TargetFramework>net8.0</TargetFramework>
|
| 4 |
+
<Nullable>enable</Nullable>
|
| 5 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 6 |
+
<RootNamespace>OptimAI.BRE.Gateway</RootNamespace>
|
| 7 |
+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
| 8 |
+
<NoWarn>$(NoWarn);1591</NoWarn>
|
| 9 |
+
</PropertyGroup>
|
| 10 |
+
|
| 11 |
+
<ItemGroup>
|
| 12 |
+
<!-- Core ASP.NET & EF -->
|
| 13 |
+
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.1" />
|
| 14 |
+
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.1">
|
| 15 |
+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
| 16 |
+
<PrivateAssets>all</PrivateAssets>
|
| 17 |
+
</PackageReference>
|
| 18 |
+
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.2" />
|
| 19 |
+
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />
|
| 20 |
+
|
| 21 |
+
<!-- Authentication -->
|
| 22 |
+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.1" />
|
| 23 |
+
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.3.1" />
|
| 24 |
+
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.3.1" />
|
| 25 |
+
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
| 26 |
+
|
| 27 |
+
<!-- Redis -->
|
| 28 |
+
<PackageReference Include="StackExchange.Redis" Version="2.7.17" />
|
| 29 |
+
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.1" />
|
| 30 |
+
|
| 31 |
+
<!-- Azure OpenAI -->
|
| 32 |
+
<PackageReference Include="Azure.AI.OpenAI" Version="1.0.0-beta.17" />
|
| 33 |
+
|
| 34 |
+
<!-- Logging -->
|
| 35 |
+
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
| 36 |
+
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
| 37 |
+
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
| 38 |
+
<PackageReference Include="Serilog.Enrichers.Environment" Version="2.3.0" />
|
| 39 |
+
|
| 40 |
+
<!-- Swagger -->
|
| 41 |
+
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
| 42 |
+
|
| 43 |
+
<!-- Health Checks -->
|
| 44 |
+
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.1" />
|
| 45 |
+
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
|
| 46 |
+
</ItemGroup>
|
| 47 |
+
|
| 48 |
+
<ItemGroup>
|
| 49 |
+
<ProjectReference Include="..\OptimAI.BRE.Shared\OptimAI.BRE.Shared.csproj" />
|
| 50 |
+
<ProjectReference Include="..\OptimAI.BRE.RuleEngine\OptimAI.BRE.RuleEngine.csproj" />
|
| 51 |
+
<ProjectReference Include="..\OptimAI.BRE.RuleDesigner\OptimAI.BRE.RuleDesigner.csproj" />
|
| 52 |
+
<ProjectReference Include="..\OptimAI.BRE.AIEngine\OptimAI.BRE.AIEngine.csproj" />
|
| 53 |
+
<ProjectReference Include="..\OptimAI.BRE.IdentityService\OptimAI.BRE.IdentityService.csproj" />
|
| 54 |
+
<ProjectReference Include="..\OptimAI.BRE.ClientMgmt\OptimAI.BRE.ClientMgmt.csproj" />
|
| 55 |
+
<ProjectReference Include="..\OptimAI.BRE.AuditService\OptimAI.BRE.AuditService.csproj" />
|
| 56 |
+
<ProjectReference Include="..\OptimAI.BRE.ReportService\OptimAI.BRE.ReportService.csproj" />
|
| 57 |
+
</ItemGroup>
|
| 58 |
+
</Project>
|
OptimAI.BRE.IdentityService.csproj
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
<PropertyGroup>
|
| 3 |
+
<TargetFramework>net8.0</TargetFramework>
|
| 4 |
+
<Nullable>enable</Nullable>
|
| 5 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 6 |
+
<RootNamespace>OptimAI.BRE.IdentityService</RootNamespace>
|
| 7 |
+
</PropertyGroup>
|
| 8 |
+
<ItemGroup>
|
| 9 |
+
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
| 10 |
+
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
| 11 |
+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.1" />
|
| 12 |
+
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.3.1" />
|
| 13 |
+
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.3.1" />
|
| 14 |
+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
| 15 |
+
</ItemGroup>
|
| 16 |
+
<ItemGroup>
|
| 17 |
+
<ProjectReference Include="..\OptimAI.BRE.Shared\OptimAI.BRE.Shared.csproj" />
|
| 18 |
+
<ProjectReference Include="..\OptimAI.BRE.RuleEngine\OptimAI.BRE.RuleEngine.csproj" />
|
| 19 |
+
</ItemGroup>
|
| 20 |
+
</Project>
|
OptimAI.BRE.ReportService.csproj
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
<PropertyGroup>
|
| 3 |
+
<TargetFramework>net8.0</TargetFramework>
|
| 4 |
+
<Nullable>enable</Nullable>
|
| 5 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 6 |
+
<RootNamespace>OptimAI.BRE.ReportService</RootNamespace>
|
| 7 |
+
</PropertyGroup>
|
| 8 |
+
<ItemGroup>
|
| 9 |
+
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
| 10 |
+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
| 11 |
+
</ItemGroup>
|
| 12 |
+
<ItemGroup>
|
| 13 |
+
<ProjectReference Include="..\OptimAI.BRE.Shared\OptimAI.BRE.Shared.csproj" />
|
| 14 |
+
<ProjectReference Include="..\OptimAI.BRE.RuleEngine\OptimAI.BRE.RuleEngine.csproj" />
|
| 15 |
+
</ItemGroup>
|
| 16 |
+
</Project>
|
OptimAI.BRE.RuleDesigner.csproj
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
<PropertyGroup>
|
| 3 |
+
<TargetFramework>net8.0</TargetFramework>
|
| 4 |
+
<Nullable>enable</Nullable>
|
| 5 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 6 |
+
<RootNamespace>OptimAI.BRE.RuleDesigner</RootNamespace>
|
| 7 |
+
</PropertyGroup>
|
| 8 |
+
<ItemGroup>
|
| 9 |
+
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
| 10 |
+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
| 11 |
+
</ItemGroup>
|
| 12 |
+
<ItemGroup>
|
| 13 |
+
<ProjectReference Include="..\OptimAI.BRE.Shared\OptimAI.BRE.Shared.csproj" />
|
| 14 |
+
<ProjectReference Include="..\OptimAI.BRE.RuleEngine\OptimAI.BRE.RuleEngine.csproj" />
|
| 15 |
+
</ItemGroup>
|
| 16 |
+
</Project>
|
OptimAI.BRE.RuleEngine.csproj
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
<PropertyGroup>
|
| 3 |
+
<TargetFramework>net8.0</TargetFramework>
|
| 4 |
+
<Nullable>enable</Nullable>
|
| 5 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 6 |
+
<RootNamespace>OptimAI.BRE.RuleEngine</RootNamespace>
|
| 7 |
+
</PropertyGroup>
|
| 8 |
+
<ItemGroup>
|
| 9 |
+
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.1" />
|
| 10 |
+
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.1" />
|
| 11 |
+
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.2" />
|
| 12 |
+
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />
|
| 13 |
+
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.1" />
|
| 14 |
+
<PackageReference Include="StackExchange.Redis" Version="2.7.17" />
|
| 15 |
+
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
| 16 |
+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
| 17 |
+
</ItemGroup>
|
| 18 |
+
<ItemGroup>
|
| 19 |
+
<ProjectReference Include="..\OptimAI.BRE.Shared\OptimAI.BRE.Shared.csproj" />
|
| 20 |
+
</ItemGroup>
|
| 21 |
+
</Project>
|
OptimAI.BRE.Shared.csproj
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
<PropertyGroup>
|
| 3 |
+
<TargetFramework>net8.0</TargetFramework>
|
| 4 |
+
<Nullable>enable</Nullable>
|
| 5 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 6 |
+
<RootNamespace>OptimAI.BRE.Shared</RootNamespace>
|
| 7 |
+
</PropertyGroup>
|
| 8 |
+
<ItemGroup>
|
| 9 |
+
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.1" />
|
| 10 |
+
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="8.0.1" />
|
| 11 |
+
</ItemGroup>
|
| 12 |
+
</Project>
|
OptimAI.BRE.sln
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Microsoft Visual Studio Solution File, Format Version 12.00
|
| 3 |
+
# Visual Studio Version 17
|
| 4 |
+
VisualStudioVersion = 17.8.0.0
|
| 5 |
+
MinimumVisualStudioVersion = 10.0.40219.1
|
| 6 |
+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OptimAI.BRE.Gateway", "OptimAI.BRE.Gateway\OptimAI.BRE.Gateway.csproj", "{A1B2C3D4-0001-0001-0001-000000000001}"
|
| 7 |
+
EndProject
|
| 8 |
+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OptimAI.BRE.Shared", "OptimAI.BRE.Shared\OptimAI.BRE.Shared.csproj", "{A1B2C3D4-0002-0002-0002-000000000002}"
|
| 9 |
+
EndProject
|
| 10 |
+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OptimAI.BRE.RuleEngine", "OptimAI.BRE.RuleEngine\OptimAI.BRE.RuleEngine.csproj", "{A1B2C3D4-0003-0003-0003-000000000003}"
|
| 11 |
+
EndProject
|
| 12 |
+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OptimAI.BRE.RuleDesigner", "OptimAI.BRE.RuleDesigner\OptimAI.BRE.RuleDesigner.csproj", "{A1B2C3D4-0004-0004-0004-000000000004}"
|
| 13 |
+
EndProject
|
| 14 |
+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OptimAI.BRE.AIEngine", "OptimAI.BRE.AIEngine\OptimAI.BRE.AIEngine.csproj", "{A1B2C3D4-0005-0005-0005-000000000005}"
|
| 15 |
+
EndProject
|
| 16 |
+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OptimAI.BRE.IdentityService", "OptimAI.BRE.IdentityService\OptimAI.BRE.IdentityService.csproj", "{A1B2C3D4-0006-0006-0006-000000000006}"
|
| 17 |
+
EndProject
|
| 18 |
+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OptimAI.BRE.ClientMgmt", "OptimAI.BRE.ClientMgmt\OptimAI.BRE.ClientMgmt.csproj", "{A1B2C3D4-0007-0007-0007-000000000007}"
|
| 19 |
+
EndProject
|
| 20 |
+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OptimAI.BRE.AuditService", "OptimAI.BRE.AuditService\OptimAI.BRE.AuditService.csproj", "{A1B2C3D4-0008-0008-0008-000000000008}"
|
| 21 |
+
EndProject
|
| 22 |
+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OptimAI.BRE.ReportService", "OptimAI.BRE.ReportService\OptimAI.BRE.ReportService.csproj", "{A1B2C3D4-0009-0009-0009-000000000009}"
|
| 23 |
+
EndProject
|
| 24 |
+
Global
|
| 25 |
+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
| 26 |
+
Debug|Any CPU = Debug|Any CPU
|
| 27 |
+
Release|Any CPU = Release|Any CPU
|
| 28 |
+
EndGlobalSection
|
| 29 |
+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
| 30 |
+
{A1B2C3D4-0001-0001-0001-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
| 31 |
+
{A1B2C3D4-0001-0001-0001-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
| 32 |
+
{A1B2C3D4-0001-0001-0001-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
| 33 |
+
{A1B2C3D4-0001-0001-0001-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
| 34 |
+
EndGlobalSection
|
| 35 |
+
EndGlobal
|
Program.cs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Text;
|
| 2 |
+
using System.Threading.RateLimiting;
|
| 3 |
+
using Azure.AI.OpenAI;
|
| 4 |
+
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
| 5 |
+
using Microsoft.AspNetCore.RateLimiting;
|
| 6 |
+
using Microsoft.EntityFrameworkCore;
|
| 7 |
+
using Microsoft.IdentityModel.Tokens;
|
| 8 |
+
using Microsoft.OpenApi.Models;
|
| 9 |
+
using OptimAI.BRE.AIEngine.Application;
|
| 10 |
+
using OptimAI.BRE.RuleEngine.Api;
|
| 11 |
+
using OptimAI.BRE.RuleEngine.Application;
|
| 12 |
+
using OptimAI.BRE.RuleEngine.Domain;
|
| 13 |
+
using OptimAI.BRE.RuleEngine.Infrastructure;
|
| 14 |
+
using Serilog;
|
| 15 |
+
using StackExchange.Redis;
|
| 16 |
+
|
| 17 |
+
var builder = WebApplication.CreateBuilder(args);
|
| 18 |
+
|
| 19 |
+
// ============================================================
|
| 20 |
+
// LOGGING - Serilog
|
| 21 |
+
// ============================================================
|
| 22 |
+
builder.Host.UseSerilog((ctx, lc) => lc
|
| 23 |
+
.ReadFrom.Configuration(ctx.Configuration)
|
| 24 |
+
.Enrich.FromLogContext()
|
| 25 |
+
.Enrich.WithProperty("Application", "OptimAI.BRE")
|
| 26 |
+
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
|
| 27 |
+
.WriteTo.File("logs/optim-ai-bre-.log", rollingInterval: RollingInterval.Day));
|
| 28 |
+
|
| 29 |
+
// ============================================================
|
| 30 |
+
// DATABASE
|
| 31 |
+
// ============================================================
|
| 32 |
+
builder.Services.AddDbContext<BREDbContext>(opts =>
|
| 33 |
+
opts.UseNpgsql(
|
| 34 |
+
builder.Configuration.GetConnectionString("PostgreSQL"),
|
| 35 |
+
npg => npg.EnableRetryOnFailure(3)
|
| 36 |
+
)
|
| 37 |
+
.UseSnakeCaseNamingConvention()
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
// ============================================================
|
| 41 |
+
// REDIS CACHE
|
| 42 |
+
// ============================================================
|
| 43 |
+
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
|
| 44 |
+
ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!));
|
| 45 |
+
|
| 46 |
+
builder.Services.AddStackExchangeRedisCache(opts =>
|
| 47 |
+
opts.Configuration = builder.Configuration.GetConnectionString("Redis"));
|
| 48 |
+
|
| 49 |
+
// ============================================================
|
| 50 |
+
// AUTHENTICATION & AUTHORIZATION
|
| 51 |
+
// ============================================================
|
| 52 |
+
var jwtSettings = builder.Configuration.GetSection("Jwt");
|
| 53 |
+
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
| 54 |
+
.AddJwtBearer(opts =>
|
| 55 |
+
{
|
| 56 |
+
opts.TokenValidationParameters = new TokenValidationParameters
|
| 57 |
+
{
|
| 58 |
+
ValidateIssuer = true,
|
| 59 |
+
ValidateAudience = true,
|
| 60 |
+
ValidateLifetime = true,
|
| 61 |
+
ValidateIssuerSigningKey = true,
|
| 62 |
+
ValidIssuer = jwtSettings["Issuer"],
|
| 63 |
+
ValidAudience = jwtSettings["Audience"],
|
| 64 |
+
IssuerSigningKey = new SymmetricSecurityKey(
|
| 65 |
+
Encoding.UTF8.GetBytes(jwtSettings["SecretKey"]!)),
|
| 66 |
+
ClockSkew = TimeSpan.FromMinutes(1)
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
opts.Events = new JwtBearerEvents
|
| 70 |
+
{
|
| 71 |
+
OnAuthenticationFailed = ctx =>
|
| 72 |
+
{
|
| 73 |
+
Log.Warning("JWT auth failed: {Error}", ctx.Exception.Message);
|
| 74 |
+
return Task.CompletedTask;
|
| 75 |
+
}
|
| 76 |
+
};
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
builder.Services.AddAuthorizationBuilder()
|
| 80 |
+
.AddPolicy("RuleWrite", p => p.RequireClaim("permission", "RULE.CREATE", "RULE.EDIT"))
|
| 81 |
+
.AddPolicy("RuleApprove", p => p.RequireClaim("permission", "RULE.APPROVE"))
|
| 82 |
+
.AddPolicy("RulePublish", p => p.RequireClaim("permission", "RULE.PUBLISH"))
|
| 83 |
+
.AddPolicy("SandboxAccess", p => p.RequireClaim("permission", "EXECUTION.SANDBOX"))
|
| 84 |
+
.AddPolicy("AiGenerate", p => p.RequireClaim("permission", "AI.GENERATE"))
|
| 85 |
+
.AddPolicy("AiAnalysis", p => p.RequireClaim("permission", "AI.ANALYSIS"))
|
| 86 |
+
.AddPolicy("AdminOnly", p => p.RequireClaim("permission", "ADMIN.FULL"));
|
| 87 |
+
|
| 88 |
+
// ============================================================
|
| 89 |
+
// RATE LIMITING
|
| 90 |
+
// ============================================================
|
| 91 |
+
builder.Services.AddRateLimiter(opts =>
|
| 92 |
+
{
|
| 93 |
+
opts.AddTokenBucketLimiter("bre-execution", cfg =>
|
| 94 |
+
{
|
| 95 |
+
cfg.TokenLimit = 1000;
|
| 96 |
+
cfg.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
| 97 |
+
cfg.QueueLimit = 100;
|
| 98 |
+
cfg.ReplenishmentPeriod = TimeSpan.FromMinutes(1);
|
| 99 |
+
cfg.TokensPerPeriod = 1000;
|
| 100 |
+
cfg.AutoReplenishment = true;
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
opts.AddFixedWindowLimiter("api-standard", cfg =>
|
| 104 |
+
{
|
| 105 |
+
cfg.PermitLimit = 500;
|
| 106 |
+
cfg.Window = TimeSpan.FromMinutes(1);
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
opts.OnRejected = async (ctx, ct) =>
|
| 110 |
+
{
|
| 111 |
+
ctx.HttpContext.Response.StatusCode = 429;
|
| 112 |
+
await ctx.HttpContext.Response.WriteAsJsonAsync(new
|
| 113 |
+
{
|
| 114 |
+
error = "Rate limit exceeded",
|
| 115 |
+
retryAfter = ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retry) ? retry.TotalSeconds : 60
|
| 116 |
+
}, ct);
|
| 117 |
+
};
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
// ============================================================
|
| 121 |
+
// HEALTH CHECKS
|
| 122 |
+
// ============================================================
|
| 123 |
+
builder.Services.AddHealthChecks()
|
| 124 |
+
.AddNpgSql(builder.Configuration.GetConnectionString("PostgreSQL")!, name: "postgresql")
|
| 125 |
+
.AddRedis(builder.Configuration.GetConnectionString("Redis")!, name: "redis");
|
| 126 |
+
|
| 127 |
+
// ============================================================
|
| 128 |
+
// OPENAI / AZURE OPENAI
|
| 129 |
+
// ============================================================
|
| 130 |
+
builder.Services.Configure<AiOptions>(builder.Configuration.GetSection("AiOptions"));
|
| 131 |
+
builder.Services.AddSingleton<OpenAIClient>(_ =>
|
| 132 |
+
{
|
| 133 |
+
var aiConfig = builder.Configuration.GetSection("AiOptions");
|
| 134 |
+
return aiConfig.GetValue<bool>("UseAzureOpenAI")
|
| 135 |
+
? new OpenAIClient(new Uri(aiConfig["Endpoint"]!), new Azure.AzureKeyCredential(aiConfig["ApiKey"]!))
|
| 136 |
+
: new OpenAIClient(aiConfig["ApiKey"]);
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
// ============================================================
|
| 140 |
+
// BRE SERVICES
|
| 141 |
+
// ============================================================
|
| 142 |
+
builder.Services.AddScoped<IRuleExecutionService, RuleExecutionService>();
|
| 143 |
+
builder.Services.AddScoped<IConditionEvaluator, ConditionEvaluator>();
|
| 144 |
+
builder.Services.AddScoped<IActionExecutor, ActionExecutor>();
|
| 145 |
+
builder.Services.AddScoped<IRiskScoringEngine, RiskScoringEngine>();
|
| 146 |
+
builder.Services.AddScoped<IDynamicFieldResolver, DynamicFieldResolver>();
|
| 147 |
+
builder.Services.AddScoped<IRuleLoader, CachedRuleLoader>();
|
| 148 |
+
builder.Services.AddScoped<IAiCreditAnalystService, AiCreditAnalystService>();
|
| 149 |
+
builder.Services.AddScoped<ITenantContextAccessor, TenantContextAccessor>();
|
| 150 |
+
|
| 151 |
+
// Infrastructure repositories
|
| 152 |
+
builder.Services.AddScoped<IDeviationTypeRepository, DeviationTypeRepository>();
|
| 153 |
+
builder.Services.AddScoped<IRiskWeightRepository, RiskWeightRepository>();
|
| 154 |
+
builder.Services.AddScoped<IExecutionRequestRepository, ExecutionRequestRepository>();
|
| 155 |
+
builder.Services.AddScoped<IExecutionResultRepository, ExecutionResultRepository>();
|
| 156 |
+
builder.Services.AddScoped<IDecisionReportService, DecisionReportService>();
|
| 157 |
+
builder.Services.AddScoped<ISandboxService, SandboxService>();
|
| 158 |
+
builder.Services.AddScoped<IAiPromptRepository, AiPromptRepository>();
|
| 159 |
+
builder.Services.AddScoped<IAiRuleGeneratorRepository, AiRuleGeneratorRepository>();
|
| 160 |
+
builder.Services.AddScoped<IFieldCatalogRepository, FieldCatalogRepository>();
|
| 161 |
+
builder.Services.AddScoped<IRuleRepository, RuleRepository>();
|
| 162 |
+
builder.Services.AddScoped<IRuleVersionRepository, RuleVersionRepository>();
|
| 163 |
+
builder.Services.AddScoped<IRuleApprovalService, RuleApprovalService>();
|
| 164 |
+
builder.Services.AddScoped<IAuditService, AuditService>();
|
| 165 |
+
|
| 166 |
+
// ============================================================
|
| 167 |
+
// API & SWAGGER
|
| 168 |
+
// ============================================================
|
| 169 |
+
builder.Services.AddControllers()
|
| 170 |
+
.AddJsonOptions(opts =>
|
| 171 |
+
{
|
| 172 |
+
opts.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
| 173 |
+
opts.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
builder.Services.AddEndpointsApiExplorer();
|
| 177 |
+
builder.Services.AddSwaggerGen(c =>
|
| 178 |
+
{
|
| 179 |
+
c.SwaggerDoc("v1", new OpenApiInfo
|
| 180 |
+
{
|
| 181 |
+
Title = "OPTIM AI BRE Engine API",
|
| 182 |
+
Version = "v1",
|
| 183 |
+
Description = "Enterprise AI-Powered Business Rule Engine for Banks, NBFCs, and Lending Institutions",
|
| 184 |
+
Contact = new OpenApiContact
|
| 185 |
+
{
|
| 186 |
+
Name = "OPTIM AI Support",
|
| 187 |
+
Email = "support@optimai.in"
|
| 188 |
+
}
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
| 192 |
+
{
|
| 193 |
+
Type = SecuritySchemeType.Http,
|
| 194 |
+
Scheme = "bearer",
|
| 195 |
+
BearerFormat = "JWT",
|
| 196 |
+
Description = "Enter JWT token"
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
c.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme
|
| 200 |
+
{
|
| 201 |
+
Type = SecuritySchemeType.ApiKey,
|
| 202 |
+
In = ParameterLocation.Header,
|
| 203 |
+
Name = "X-API-Key",
|
| 204 |
+
Description = "API Key authentication"
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
c.AddSecurityRequirement(new OpenApiSecurityRequirement
|
| 208 |
+
{
|
| 209 |
+
{
|
| 210 |
+
new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } },
|
| 211 |
+
Array.Empty<string>()
|
| 212 |
+
}
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
| 216 |
+
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
| 217 |
+
if (File.Exists(xmlPath)) c.IncludeXmlComments(xmlPath);
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
// CORS
|
| 221 |
+
builder.Services.AddCors(opts =>
|
| 222 |
+
opts.AddPolicy("AllowFrontend", p => p
|
| 223 |
+
.WithOrigins(builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? new[] { "*" })
|
| 224 |
+
.AllowAnyMethod()
|
| 225 |
+
.AllowAnyHeader()
|
| 226 |
+
.AllowCredentials()));
|
| 227 |
+
|
| 228 |
+
// ============================================================
|
| 229 |
+
// BUILD APP
|
| 230 |
+
// ============================================================
|
| 231 |
+
var app = builder.Build();
|
| 232 |
+
|
| 233 |
+
app.UseSerilogRequestLogging(opts =>
|
| 234 |
+
{
|
| 235 |
+
opts.EnrichDiagnosticContext = (diag, ctx) =>
|
| 236 |
+
{
|
| 237 |
+
diag.Set("TenantId", ctx.Request.Headers["X-Tenant-ID"].FirstOrDefault() ?? "");
|
| 238 |
+
diag.Set("RequestId", ctx.TraceIdentifier);
|
| 239 |
+
};
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
if (app.Environment.IsDevelopment() || app.Environment.IsStaging())
|
| 243 |
+
{
|
| 244 |
+
app.UseSwagger();
|
| 245 |
+
app.UseSwaggerUI(c =>
|
| 246 |
+
{
|
| 247 |
+
c.SwaggerEndpoint("/swagger/v1/swagger.json", "OPTIM AI BRE v1");
|
| 248 |
+
c.RoutePrefix = "swagger";
|
| 249 |
+
c.DocExpansion(Swashbuckle.AspNetCore.SwaggerUI.DocExpansion.None);
|
| 250 |
+
});
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
app.UseHttpsRedirection();
|
| 254 |
+
app.UseCors("AllowFrontend");
|
| 255 |
+
app.UseRateLimiter();
|
| 256 |
+
|
| 257 |
+
// Tenant resolution middleware
|
| 258 |
+
app.UseMiddleware<TenantResolutionMiddleware>();
|
| 259 |
+
app.UseMiddleware<ApiKeyMiddleware>();
|
| 260 |
+
|
| 261 |
+
app.UseAuthentication();
|
| 262 |
+
app.UseAuthorization();
|
| 263 |
+
|
| 264 |
+
app.MapControllers();
|
| 265 |
+
app.MapHealthChecks("/health");
|
| 266 |
+
app.MapHealthChecks("/health/ready");
|
| 267 |
+
|
| 268 |
+
// Auto-migrate on startup
|
| 269 |
+
using (var scope = app.Services.CreateScope())
|
| 270 |
+
{
|
| 271 |
+
var db = scope.ServiceProvider.GetRequiredService<BREDbContext>();
|
| 272 |
+
await db.Database.MigrateAsync();
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
await app.RunAsync();
|
RepositoryStubs.cs
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================
|
| 2 |
+
// REPOSITORY STUB IMPLEMENTATIONS
|
| 3 |
+
// These are functional local-dev implementations.
|
| 4 |
+
// Replace with full implementations per module.
|
| 5 |
+
// ============================================================
|
| 6 |
+
|
| 7 |
+
using Microsoft.EntityFrameworkCore;
|
| 8 |
+
using Microsoft.Extensions.Caching.Distributed;
|
| 9 |
+
using OptimAI.BRE.AIEngine.Api;
|
| 10 |
+
using OptimAI.BRE.RuleDesigner.Api;
|
| 11 |
+
using OptimAI.BRE.RuleEngine.Api;
|
| 12 |
+
using OptimAI.BRE.RuleEngine.Application;
|
| 13 |
+
using OptimAI.BRE.RuleEngine.Domain;
|
| 14 |
+
using OptimAI.BRE.Shared.Domain;
|
| 15 |
+
using System.Text.Json;
|
| 16 |
+
|
| 17 |
+
namespace OptimAI.BRE.RuleEngine.Infrastructure;
|
| 18 |
+
|
| 19 |
+
// ---- DynamicFieldResolver ----
|
| 20 |
+
public class DynamicFieldResolver : IDynamicFieldResolver
|
| 21 |
+
{
|
| 22 |
+
public object? Resolve(string fieldPath, DynamicDataContext context) => context.GetValue(fieldPath);
|
| 23 |
+
|
| 24 |
+
public bool TryResolve(string fieldPath, DynamicDataContext context, out object? value)
|
| 25 |
+
{
|
| 26 |
+
value = context.GetValue(fieldPath);
|
| 27 |
+
return true;
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// ---- DeviationTypeRepository ----
|
| 32 |
+
public class DeviationTypeRepository : IDeviationTypeRepository
|
| 33 |
+
{
|
| 34 |
+
private readonly BREDbContext _db;
|
| 35 |
+
public DeviationTypeRepository(BREDbContext db) => _db = db;
|
| 36 |
+
|
| 37 |
+
public async Task<DeviationType?> FindByCodeAsync(Guid tenantId, string code, CancellationToken ct = default)
|
| 38 |
+
=> await _db.DeviationTypes.FirstOrDefaultAsync(d =>
|
| 39 |
+
(d.TenantId == tenantId || d.TenantId == null) && d.DeviationCode == code, ct);
|
| 40 |
+
|
| 41 |
+
public async Task<IReadOnlyList<DeviationType>> GetAllActiveAsync(Guid tenantId, CancellationToken ct = default)
|
| 42 |
+
=> await _db.DeviationTypes
|
| 43 |
+
.Where(d => (d.TenantId == tenantId || d.TenantId == null) && d.IsActive)
|
| 44 |
+
.ToListAsync(ct);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// ---- RiskWeightRepository ----
|
| 48 |
+
public class RiskWeightRepository : IRiskWeightRepository
|
| 49 |
+
{
|
| 50 |
+
public Task<Dictionary<string, decimal>> GetWeightsAsync(Guid tenantId, string? productCode = null)
|
| 51 |
+
=> Task.FromResult(new Dictionary<string, decimal>
|
| 52 |
+
{
|
| 53 |
+
["bureau"] = 30, ["income"] = 25, ["fi"] = 15,
|
| 54 |
+
["fraud"] = 15, ["vehicle"] = 10, ["employment"] = 10, ["rule_penalty"] = 5
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// ---- ExecutionRequestRepository ----
|
| 59 |
+
public class ExecutionRequestRepository : IExecutionRequestRepository
|
| 60 |
+
{
|
| 61 |
+
private readonly BREDbContext _db;
|
| 62 |
+
public ExecutionRequestRepository(BREDbContext db) => _db = db;
|
| 63 |
+
|
| 64 |
+
public async Task<ExecutionRequest> CreateAsync(ExecutionRequest request, CancellationToken ct = default)
|
| 65 |
+
{
|
| 66 |
+
_db.ExecutionRequests.Add(request);
|
| 67 |
+
await _db.SaveChangesAsync(ct);
|
| 68 |
+
return request;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
public async Task MarkCompletedAsync(Guid id, CancellationToken ct = default)
|
| 72 |
+
{
|
| 73 |
+
await _db.ExecutionRequests.Where(r => r.Id == id)
|
| 74 |
+
.ExecuteUpdateAsync(s => s
|
| 75 |
+
.SetProperty(r => r.Status, ExecutionStatus.Completed)
|
| 76 |
+
.SetProperty(r => r.CompletedAt, DateTime.UtcNow), ct);
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// ---- ExecutionResultRepository ----
|
| 81 |
+
public class ExecutionResultRepository : IExecutionResultRepository
|
| 82 |
+
{
|
| 83 |
+
private readonly BREDbContext _db;
|
| 84 |
+
public ExecutionResultRepository(BREDbContext db) => _db = db;
|
| 85 |
+
|
| 86 |
+
public async Task SaveAsync(ExecutionResult result, CancellationToken ct = default)
|
| 87 |
+
{
|
| 88 |
+
_db.ExecutionResults.Add(result);
|
| 89 |
+
await _db.SaveChangesAsync(ct);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
public async Task<ExecutionResult?> GetByRequestIdAsync(Guid requestId, Guid tenantId, CancellationToken ct = default)
|
| 93 |
+
=> await _db.ExecutionResults.FirstOrDefaultAsync(r => r.RequestId == requestId && r.TenantId == tenantId, ct);
|
| 94 |
+
|
| 95 |
+
public async Task<PagedResponse<DecisionSummaryDto>> GetHistoryAsync(DecisionHistoryQuery query, CancellationToken ct = default)
|
| 96 |
+
{
|
| 97 |
+
var q = _db.ExecutionResults
|
| 98 |
+
.Join(_db.ExecutionRequests, r => r.RequestId, req => req.Id, (r, req) => new { r, req })
|
| 99 |
+
.Where(x => x.r.TenantId == query.TenantId);
|
| 100 |
+
|
| 101 |
+
if (query.Decision != null) q = q.Where(x => x.r.FinalDecision.ToString() == query.Decision);
|
| 102 |
+
if (query.FromDate.HasValue) q = q.Where(x => x.req.CreatedAt >= query.FromDate);
|
| 103 |
+
if (query.ToDate.HasValue) q = q.Where(x => x.req.CreatedAt <= query.ToDate);
|
| 104 |
+
|
| 105 |
+
var total = await q.CountAsync(ct);
|
| 106 |
+
var items = await q.OrderByDescending(x => x.req.CreatedAt)
|
| 107 |
+
.Skip((query.Page - 1) * query.PageSize)
|
| 108 |
+
.Take(query.PageSize)
|
| 109 |
+
.Select(x => new DecisionSummaryDto
|
| 110 |
+
{
|
| 111 |
+
RequestId = x.r.RequestId,
|
| 112 |
+
ApplicationId = x.req.ApplicationId,
|
| 113 |
+
Decision = x.r.FinalDecision.ToString(),
|
| 114 |
+
TrafficLight = x.r.TrafficLight.ToString() ?? "AMBER",
|
| 115 |
+
RiskScore = x.r.RiskScore ?? 0,
|
| 116 |
+
DeviationsCount = x.r.DeviationsCount,
|
| 117 |
+
CreatedAt = x.req.CreatedAt
|
| 118 |
+
})
|
| 119 |
+
.ToListAsync(ct);
|
| 120 |
+
|
| 121 |
+
return new PagedResponse<DecisionSummaryDto>
|
| 122 |
+
{
|
| 123 |
+
Items = items, TotalCount = total,
|
| 124 |
+
Page = query.Page, PageSize = query.PageSize
|
| 125 |
+
};
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// ---- DecisionReportService ----
|
| 130 |
+
public class DecisionReportService : IDecisionReportService
|
| 131 |
+
{
|
| 132 |
+
private readonly BREDbContext _db;
|
| 133 |
+
public DecisionReportService(BREDbContext db) => _db = db;
|
| 134 |
+
|
| 135 |
+
public async Task<DecisionReport?> GenerateAsync(Guid requestId, ExecutionResult result, CancellationToken ct = default)
|
| 136 |
+
{
|
| 137 |
+
var report = new DecisionReportEntity
|
| 138 |
+
{
|
| 139 |
+
RequestId = requestId,
|
| 140 |
+
TenantId = result.TenantId,
|
| 141 |
+
FinalDecision = result.FinalDecision.ToString(),
|
| 142 |
+
RiskScore = result.RiskScore,
|
| 143 |
+
RiskCategory = result.RiskCategory?.ToString(),
|
| 144 |
+
TrafficLight = result.TrafficLight?.ToString(),
|
| 145 |
+
ReportJson = JsonSerializer.Serialize(result),
|
| 146 |
+
GeneratedAt = DateTime.UtcNow
|
| 147 |
+
};
|
| 148 |
+
_db.DecisionReports.Add(report);
|
| 149 |
+
await _db.SaveChangesAsync(ct);
|
| 150 |
+
return new DecisionReport { Id = report.Id };
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// ---- SandboxService ----
|
| 155 |
+
public class SandboxService : ISandboxService
|
| 156 |
+
{
|
| 157 |
+
private readonly IRuleExecutionService _exec;
|
| 158 |
+
private readonly IRuleLoader _loader;
|
| 159 |
+
public SandboxService(IRuleExecutionService exec, IRuleLoader loader)
|
| 160 |
+
{
|
| 161 |
+
_exec = exec;
|
| 162 |
+
_loader = loader;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
public async Task<object> SimulateAsync(SandboxRequest request, CancellationToken ct = default)
|
| 166 |
+
{
|
| 167 |
+
var context = new ExecutionContext
|
| 168 |
+
{
|
| 169 |
+
TenantId = request.TenantId,
|
| 170 |
+
CorrelationId = $"sandbox-{Guid.NewGuid()}",
|
| 171 |
+
RuleSetId = request.RuleSetId,
|
| 172 |
+
Data = new DynamicDataContext(request.TestData),
|
| 173 |
+
Options = new ExecutionOptions { EnableAiAnalysis = false }
|
| 174 |
+
};
|
| 175 |
+
return await _exec.ExecuteAsync(context, ct);
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// ---- AiPromptRepository ----
|
| 180 |
+
public class AiPromptRepository : IAiPromptRepository
|
| 181 |
+
{
|
| 182 |
+
private readonly BREDbContext _db;
|
| 183 |
+
public AiPromptRepository(BREDbContext db) => _db = db;
|
| 184 |
+
|
| 185 |
+
public async Task<string?> GetPromptAsync(string promptCode, CancellationToken ct = default)
|
| 186 |
+
{
|
| 187 |
+
var p = await _db.Set<AiPromptEntity>()
|
| 188 |
+
.FirstOrDefaultAsync(x => x.PromptCode == promptCode && x.IsActive, ct);
|
| 189 |
+
return p?.PromptTemplate;
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// ---- AiRuleGeneratorRepository ----
|
| 194 |
+
public class AiRuleGeneratorRepository : IAiRuleGeneratorRepository
|
| 195 |
+
{
|
| 196 |
+
private readonly BREDbContext _db;
|
| 197 |
+
public AiRuleGeneratorRepository(BREDbContext db) => _db = db;
|
| 198 |
+
|
| 199 |
+
public async Task<Guid> SaveGeneratedRuleAsync(AiGeneratedRuleRecord record, CancellationToken ct = default)
|
| 200 |
+
{
|
| 201 |
+
var entity = new AiGeneratedRuleEntity
|
| 202 |
+
{
|
| 203 |
+
TenantId = record.TenantId,
|
| 204 |
+
UserPrompt = record.UserPrompt,
|
| 205 |
+
GeneratedRule = JsonSerializer.Serialize(record.GeneratedRule),
|
| 206 |
+
CreatedBy = record.CreatedBy
|
| 207 |
+
};
|
| 208 |
+
_db.AiGeneratedRules.Add(entity);
|
| 209 |
+
await _db.SaveChangesAsync(ct);
|
| 210 |
+
return entity.Id;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
public async Task<bool> AcceptRuleAsync(Guid generationId, Guid tenantId, Guid? categoryId, Guid userId, CancellationToken ct = default)
|
| 214 |
+
{
|
| 215 |
+
var entity = await _db.AiGeneratedRules
|
| 216 |
+
.FirstOrDefaultAsync(e => e.Id == generationId && e.TenantId == tenantId, ct);
|
| 217 |
+
if (entity == null) return false;
|
| 218 |
+
entity.IsAccepted = true;
|
| 219 |
+
await _db.SaveChangesAsync(ct);
|
| 220 |
+
return true;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
public async Task<List<AiGeneratedRuleRecord>> GetHistoryAsync(Guid tenantId, int page, int pageSize, CancellationToken ct = default)
|
| 224 |
+
{
|
| 225 |
+
return await _db.AiGeneratedRules
|
| 226 |
+
.Where(e => e.TenantId == tenantId)
|
| 227 |
+
.OrderByDescending(e => e.CreatedAt)
|
| 228 |
+
.Skip((page - 1) * pageSize).Take(pageSize)
|
| 229 |
+
.Select(e => new AiGeneratedRuleRecord
|
| 230 |
+
{
|
| 231 |
+
Id = e.Id, TenantId = e.TenantId, UserPrompt = e.UserPrompt,
|
| 232 |
+
IsAccepted = e.IsAccepted, CreatedBy = e.CreatedBy, CreatedAt = e.CreatedAt
|
| 233 |
+
})
|
| 234 |
+
.ToListAsync(ct);
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// ---- FieldCatalogRepository ----
|
| 239 |
+
public class FieldCatalogRepository : IFieldCatalogRepository
|
| 240 |
+
{
|
| 241 |
+
private readonly BREDbContext _db;
|
| 242 |
+
public FieldCatalogRepository(BREDbContext db) => _db = db;
|
| 243 |
+
|
| 244 |
+
public async Task<List<string>> GetFieldPathsAsync(Guid tenantId, CancellationToken ct = default)
|
| 245 |
+
=> await _db.FieldCatalog
|
| 246 |
+
.Where(f => f.IsActive && (f.TenantId == null || f.TenantId == tenantId))
|
| 247 |
+
.Select(f => f.FieldPath).ToListAsync(ct);
|
| 248 |
+
|
| 249 |
+
public async Task<List<FieldCatalogDto>> GetCatalogAsync(Guid tenantId, string? category = null, CancellationToken ct = default)
|
| 250 |
+
{
|
| 251 |
+
var q = _db.FieldCatalog.Where(f => f.IsActive && (f.TenantId == null || f.TenantId == tenantId));
|
| 252 |
+
if (category != null) q = q.Where(f => f.Category == category);
|
| 253 |
+
return await q.Select(f => new FieldCatalogDto
|
| 254 |
+
{
|
| 255 |
+
FieldPath = f.FieldPath, DisplayName = f.DisplayName,
|
| 256 |
+
DataType = f.DataType, Category = f.Category, Description = f.Description
|
| 257 |
+
}).ToListAsync(ct);
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// ---- RuleRepository ----
|
| 262 |
+
public class RuleRepository : IRuleRepository
|
| 263 |
+
{
|
| 264 |
+
private readonly BREDbContext _db;
|
| 265 |
+
public RuleRepository(BREDbContext db) => _db = db;
|
| 266 |
+
|
| 267 |
+
public async Task<PagedResponse<RuleSummaryDto>> GetPagedAsync(RuleQuery query, CancellationToken ct = default)
|
| 268 |
+
{
|
| 269 |
+
var q = _db.Rules.Where(r => r.TenantId == query.TenantId).IgnoreQueryFilters();
|
| 270 |
+
if (query.Status != null && Enum.TryParse<RuleStatus>(query.Status, true, out var s))
|
| 271 |
+
q = q.Where(r => r.Status == s);
|
| 272 |
+
if (query.RuleType != null && Enum.TryParse<RuleType>(query.RuleType, true, out var rt))
|
| 273 |
+
q = q.Where(r => r.RuleType == rt);
|
| 274 |
+
if (!string.IsNullOrWhiteSpace(query.Search))
|
| 275 |
+
q = q.Where(r => r.RuleName.Contains(query.Search) || r.RuleCode.Contains(query.Search));
|
| 276 |
+
|
| 277 |
+
var total = await q.CountAsync(ct);
|
| 278 |
+
var items = await q.OrderBy(r => r.Priority).ThenBy(r => r.RuleName)
|
| 279 |
+
.Skip((query.Page - 1) * query.PageSize).Take(query.PageSize)
|
| 280 |
+
.Select(r => new RuleSummaryDto
|
| 281 |
+
{
|
| 282 |
+
Id = r.Id, RuleCode = r.RuleCode, RuleName = r.RuleName,
|
| 283 |
+
RuleType = r.RuleType.ToString(), Status = r.Status.ToString(),
|
| 284 |
+
IsActive = r.IsActive, IsPublished = r.IsPublished,
|
| 285 |
+
Priority = r.Priority, Tags = r.Tags,
|
| 286 |
+
CreatedAt = r.CreatedAt, UpdatedAt = r.UpdatedAt
|
| 287 |
+
}).ToListAsync(ct);
|
| 288 |
+
|
| 289 |
+
return new PagedResponse<RuleSummaryDto>
|
| 290 |
+
{ Items = items, TotalCount = total, Page = query.Page, PageSize = query.PageSize };
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
public async Task<Rule?> GetByIdAsync(Guid id, Guid tenantId, CancellationToken ct = default)
|
| 294 |
+
=> await _db.Rules.IgnoreQueryFilters()
|
| 295 |
+
.Include(r => r.CurrentVersion)
|
| 296 |
+
.Include(r => r.Scopes)
|
| 297 |
+
.Include(r => r.Category)
|
| 298 |
+
.FirstOrDefaultAsync(r => r.Id == id && r.TenantId == tenantId, ct);
|
| 299 |
+
|
| 300 |
+
public async Task<Rule> CreateWithVersionAsync(Rule rule, RuleVersion version, CancellationToken ct = default)
|
| 301 |
+
{
|
| 302 |
+
_db.Rules.Add(rule);
|
| 303 |
+
await _db.SaveChangesAsync(ct);
|
| 304 |
+
version.RuleId = rule.Id;
|
| 305 |
+
version.TenantId = rule.TenantId;
|
| 306 |
+
_db.RuleVersions.Add(version);
|
| 307 |
+
await _db.SaveChangesAsync(ct);
|
| 308 |
+
rule.CurrentVersionId = version.Id;
|
| 309 |
+
await _db.SaveChangesAsync(ct);
|
| 310 |
+
return rule;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
public async Task UpdateWithNewVersionAsync(Rule rule, RuleVersion newVersion, CancellationToken ct = default)
|
| 314 |
+
{
|
| 315 |
+
_db.Rules.Update(rule);
|
| 316 |
+
_db.RuleVersions.Add(newVersion);
|
| 317 |
+
await _db.SaveChangesAsync(ct);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
public async Task PublishAsync(Guid id, Guid tenantId, Guid publishedBy, CancellationToken ct = default)
|
| 321 |
+
{
|
| 322 |
+
var rule = await _db.Rules.IgnoreQueryFilters().Include(r => r.CurrentVersion)
|
| 323 |
+
.FirstOrDefaultAsync(r => r.Id == id && r.TenantId == tenantId, ct);
|
| 324 |
+
if (rule == null) return;
|
| 325 |
+
rule.IsPublished = true;
|
| 326 |
+
rule.IsDraft = false;
|
| 327 |
+
rule.Status = RuleStatus.Published;
|
| 328 |
+
if (rule.CurrentVersion != null)
|
| 329 |
+
{
|
| 330 |
+
rule.CurrentVersion.Status = RuleStatus.Published;
|
| 331 |
+
rule.CurrentVersion.PublishedBy = publishedBy;
|
| 332 |
+
rule.CurrentVersion.PublishedAt = DateTime.UtcNow;
|
| 333 |
+
}
|
| 334 |
+
await _db.SaveChangesAsync(ct);
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
public async Task<Rule> CloneAsync(Guid id, Guid tenantId, Guid clonedBy, string? newCode, string? newName, CancellationToken ct = default)
|
| 338 |
+
{
|
| 339 |
+
var original = await GetByIdAsync(id, tenantId, ct);
|
| 340 |
+
var clone = new Rule
|
| 341 |
+
{
|
| 342 |
+
TenantId = tenantId,
|
| 343 |
+
RuleCode = newCode ?? $"{original!.RuleCode}_copy_{DateTime.UtcNow.Ticks}",
|
| 344 |
+
RuleName = newName ?? $"{original.RuleName} (Copy)",
|
| 345 |
+
Description = original.Description,
|
| 346 |
+
CategoryId = original.CategoryId,
|
| 347 |
+
RuleType = original.RuleType,
|
| 348 |
+
Priority = original.Priority,
|
| 349 |
+
Tags = new List<string>(original.Tags),
|
| 350 |
+
Status = RuleStatus.Draft,
|
| 351 |
+
CreatedBy = clonedBy
|
| 352 |
+
};
|
| 353 |
+
var version = new RuleVersion
|
| 354 |
+
{
|
| 355 |
+
TenantId = tenantId,
|
| 356 |
+
VersionNumber = 1,
|
| 357 |
+
VersionLabel = "v1.0",
|
| 358 |
+
RuleDefinition = original!.CurrentVersion!.RuleDefinition,
|
| 359 |
+
ChangeSummary = $"Cloned from {original.RuleCode}",
|
| 360 |
+
IsCurrent = true,
|
| 361 |
+
Status = RuleStatus.Draft,
|
| 362 |
+
CreatedBy = clonedBy
|
| 363 |
+
};
|
| 364 |
+
return await CreateWithVersionAsync(clone, version, ct);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
public async Task ToggleActiveAsync(Guid id, Guid tenantId, CancellationToken ct = default)
|
| 368 |
+
{
|
| 369 |
+
var rule = await _db.Rules.IgnoreQueryFilters()
|
| 370 |
+
.FirstOrDefaultAsync(r => r.Id == id && r.TenantId == tenantId, ct);
|
| 371 |
+
if (rule == null) return;
|
| 372 |
+
rule.IsActive = !rule.IsActive;
|
| 373 |
+
await _db.SaveChangesAsync(ct);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
public async Task UpdateScopesAsync(Guid id, Guid tenantId, List<RuleScopeRequest> scopes, CancellationToken ct = default)
|
| 377 |
+
{
|
| 378 |
+
var existing = _db.RuleScopes.Where(s => s.RuleId == id);
|
| 379 |
+
_db.RuleScopes.RemoveRange(existing);
|
| 380 |
+
foreach (var s in scopes)
|
| 381 |
+
{
|
| 382 |
+
_db.RuleScopes.Add(new RuleScope
|
| 383 |
+
{
|
| 384 |
+
RuleId = id, TenantId = tenantId,
|
| 385 |
+
ScopeType = Enum.Parse<ScopeType>(s.ScopeType, true),
|
| 386 |
+
ScopeValue = s.ScopeValue, IsExcluded = s.IsExcluded
|
| 387 |
+
});
|
| 388 |
+
}
|
| 389 |
+
await _db.SaveChangesAsync(ct);
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
// ---- RuleVersionRepository ----
|
| 394 |
+
public class RuleVersionRepository : IRuleVersionRepository
|
| 395 |
+
{
|
| 396 |
+
private readonly BREDbContext _db;
|
| 397 |
+
public RuleVersionRepository(BREDbContext db) => _db = db;
|
| 398 |
+
|
| 399 |
+
public async Task<RuleVersion?> GetLatestAsync(Guid ruleId, CancellationToken ct = default)
|
| 400 |
+
=> await _db.RuleVersions.Where(v => v.RuleId == ruleId)
|
| 401 |
+
.OrderByDescending(v => v.VersionNumber).FirstOrDefaultAsync(ct);
|
| 402 |
+
|
| 403 |
+
public async Task<List<RuleVersion>> GetAllAsync(Guid ruleId, Guid tenantId, CancellationToken ct = default)
|
| 404 |
+
=> await _db.RuleVersions
|
| 405 |
+
.Where(v => v.RuleId == ruleId && v.TenantId == tenantId)
|
| 406 |
+
.OrderByDescending(v => v.VersionNumber).ToListAsync(ct);
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// ---- RuleApprovalService ----
|
| 410 |
+
public class RuleApprovalService : IRuleApprovalService
|
| 411 |
+
{
|
| 412 |
+
private readonly BREDbContext _db;
|
| 413 |
+
public RuleApprovalService(BREDbContext db) => _db = db;
|
| 414 |
+
|
| 415 |
+
public async Task SubmitForApprovalAsync(Guid ruleId, Guid versionId, Guid tenantId, Guid requestedBy, string? comments, CancellationToken ct = default)
|
| 416 |
+
{
|
| 417 |
+
await _db.Rules.IgnoreQueryFilters().Where(r => r.Id == ruleId)
|
| 418 |
+
.ExecuteUpdateAsync(s => s.SetProperty(r => r.Status, RuleStatus.PendingApproval), ct);
|
| 419 |
+
_db.RuleApprovals.Add(new RuleApproval
|
| 420 |
+
{
|
| 421 |
+
RuleId = ruleId, VersionId = versionId, TenantId = tenantId,
|
| 422 |
+
RequestedBy = requestedBy, Comments = comments, Status = "PENDING"
|
| 423 |
+
});
|
| 424 |
+
await _db.SaveChangesAsync(ct);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
public async Task ApproveAsync(Guid ruleId, Guid tenantId, Guid approvedBy, string? comments, CancellationToken ct = default)
|
| 428 |
+
{
|
| 429 |
+
await _db.Rules.IgnoreQueryFilters().Where(r => r.Id == ruleId)
|
| 430 |
+
.ExecuteUpdateAsync(s => s.SetProperty(r => r.Status, RuleStatus.Approved), ct);
|
| 431 |
+
var approval = await _db.RuleApprovals.OrderByDescending(a => a.RequestedAt)
|
| 432 |
+
.FirstOrDefaultAsync(a => a.RuleId == ruleId && a.Status == "PENDING", ct);
|
| 433 |
+
if (approval != null)
|
| 434 |
+
{
|
| 435 |
+
approval.Status = "APPROVED";
|
| 436 |
+
approval.ReviewedBy = approvedBy;
|
| 437 |
+
approval.ReviewedAt = DateTime.UtcNow;
|
| 438 |
+
approval.Comments = comments;
|
| 439 |
+
await _db.SaveChangesAsync(ct);
|
| 440 |
+
}
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
public async Task RejectAsync(Guid ruleId, Guid tenantId, Guid rejectedBy, string comments, CancellationToken ct = default)
|
| 444 |
+
{
|
| 445 |
+
await _db.Rules.IgnoreQueryFilters().Where(r => r.Id == ruleId)
|
| 446 |
+
.ExecuteUpdateAsync(s => s.SetProperty(r => r.Status, RuleStatus.Draft), ct);
|
| 447 |
+
var approval = await _db.RuleApprovals.OrderByDescending(a => a.RequestedAt)
|
| 448 |
+
.FirstOrDefaultAsync(a => a.RuleId == ruleId && a.Status == "PENDING", ct);
|
| 449 |
+
if (approval != null)
|
| 450 |
+
{
|
| 451 |
+
approval.Status = "REJECTED";
|
| 452 |
+
approval.ReviewedBy = rejectedBy;
|
| 453 |
+
approval.ReviewedAt = DateTime.UtcNow;
|
| 454 |
+
approval.Comments = comments;
|
| 455 |
+
await _db.SaveChangesAsync(ct);
|
| 456 |
+
}
|
| 457 |
+
}
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
// ---- AuditService ----
|
| 461 |
+
public class AuditService : IAuditService
|
| 462 |
+
{
|
| 463 |
+
private readonly BREDbContext _db;
|
| 464 |
+
public AuditService(BREDbContext db) => _db = db;
|
| 465 |
+
|
| 466 |
+
public async Task LogAsync(AuditLog log, CancellationToken ct = default)
|
| 467 |
+
{
|
| 468 |
+
_db.AuditLogs.Add(log);
|
| 469 |
+
await _db.SaveChangesAsync(ct);
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
// Placeholder entity for AI prompts (extend BREDbContext if needed)
|
| 474 |
+
public class AiPromptEntity
|
| 475 |
+
{
|
| 476 |
+
public Guid Id { get; set; }
|
| 477 |
+
public string PromptCode { get; set; } = default!;
|
| 478 |
+
public string PromptTemplate { get; set; } = default!;
|
| 479 |
+
public bool IsActive { get; set; } = true;
|
| 480 |
+
}
|
RiskScoringEngine.cs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using OptimAI.BRE.RuleEngine.Domain;
|
| 2 |
+
using OptimAI.BRE.Shared.Domain;
|
| 3 |
+
|
| 4 |
+
namespace OptimAI.BRE.RuleEngine.Application;
|
| 5 |
+
|
| 6 |
+
/// <summary>
|
| 7 |
+
/// Multi-dimensional risk scoring engine.
|
| 8 |
+
/// Computes weighted risk score from 0–100 across bureau, income, FI, fraud, and vehicle dimensions.
|
| 9 |
+
/// </summary>
|
| 10 |
+
public sealed class RiskScoringEngine : IRiskScoringEngine
|
| 11 |
+
{
|
| 12 |
+
private readonly IRiskWeightRepository _weightRepository;
|
| 13 |
+
|
| 14 |
+
public RiskScoringEngine(IRiskWeightRepository weightRepository)
|
| 15 |
+
{
|
| 16 |
+
_weightRepository = weightRepository;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
public async Task<RiskScoreResult> CalculateAsync(ExecutionContext context, List<RuleEvaluationResult> results)
|
| 20 |
+
{
|
| 21 |
+
var weights = await _weightRepository.GetWeightsAsync(context.TenantId, context.ProductCode);
|
| 22 |
+
var data = context.Data;
|
| 23 |
+
var componentScores = new Dictionary<string, decimal>();
|
| 24 |
+
|
| 25 |
+
// ---- 1. Bureau Score Component (0–100, inverted for risk)
|
| 26 |
+
componentScores["bureau"] = CalculateBureauScore(data, weights);
|
| 27 |
+
|
| 28 |
+
// ---- 2. Income / FOIR Component
|
| 29 |
+
componentScores["income"] = CalculateIncomeScore(data, weights);
|
| 30 |
+
|
| 31 |
+
// ---- 3. FI Component
|
| 32 |
+
componentScores["fi"] = CalculateFiScore(data, weights);
|
| 33 |
+
|
| 34 |
+
// ---- 4. Fraud Component
|
| 35 |
+
componentScores["fraud"] = CalculateFraudScore(data, weights);
|
| 36 |
+
|
| 37 |
+
// ---- 5. Vehicle Component (for vehicle loans)
|
| 38 |
+
componentScores["vehicle"] = CalculateVehicleScore(data, weights);
|
| 39 |
+
|
| 40 |
+
// ---- 6. Employment Component
|
| 41 |
+
componentScores["employment"] = CalculateEmploymentScore(data, weights);
|
| 42 |
+
|
| 43 |
+
// ---- 7. Rule Match Penalty
|
| 44 |
+
componentScores["rule_penalty"] = CalculateRulePenalty(results);
|
| 45 |
+
|
| 46 |
+
// Weighted aggregate
|
| 47 |
+
decimal totalWeight = weights.Sum(w => w.Value);
|
| 48 |
+
decimal weightedScore = componentScores.Sum(cs =>
|
| 49 |
+
{
|
| 50 |
+
var weight = weights.GetValueOrDefault(cs.Key, GetDefaultWeight(cs.Key));
|
| 51 |
+
return cs.Value * weight;
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
decimal finalScore = totalWeight > 0
|
| 55 |
+
? Math.Round(weightedScore / totalWeight, 2)
|
| 56 |
+
: Math.Round(componentScores.Values.Average(), 2);
|
| 57 |
+
|
| 58 |
+
finalScore = Math.Clamp(finalScore, 0, 100);
|
| 59 |
+
|
| 60 |
+
return new RiskScoreResult
|
| 61 |
+
{
|
| 62 |
+
Score = finalScore,
|
| 63 |
+
Category = ClassifyRisk(finalScore),
|
| 64 |
+
TrafficLight = ScoreToTrafficLight(finalScore),
|
| 65 |
+
ComponentScores = componentScores
|
| 66 |
+
};
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
private static decimal CalculateBureauScore(DynamicDataContext data, Dictionary<string, decimal> weights)
|
| 70 |
+
{
|
| 71 |
+
var cibilScore = GetDecimal(data, "bureau.cibil_score");
|
| 72 |
+
var maxDpd = GetDecimal(data, "bureau.max_dpd_24m");
|
| 73 |
+
var writtenOff = GetBool(data, "bureau.written_off_amount");
|
| 74 |
+
var suitFiled = GetBool(data, "bureau.suit_filed");
|
| 75 |
+
var wilfulDefaulter = GetBool(data, "bureau.wilful_defaulter");
|
| 76 |
+
|
| 77 |
+
decimal score = 50m; // default medium
|
| 78 |
+
|
| 79 |
+
if (cibilScore.HasValue)
|
| 80 |
+
{
|
| 81 |
+
score = cibilScore.Value switch
|
| 82 |
+
{
|
| 83 |
+
>= 780 => 5,
|
| 84 |
+
>= 750 => 15,
|
| 85 |
+
>= 720 => 25,
|
| 86 |
+
>= 700 => 35,
|
| 87 |
+
>= 680 => 45,
|
| 88 |
+
>= 650 => 60,
|
| 89 |
+
>= 620 => 70,
|
| 90 |
+
>= 600 => 80,
|
| 91 |
+
_ => 90
|
| 92 |
+
};
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// DPD penalty
|
| 96 |
+
if (maxDpd.HasValue)
|
| 97 |
+
{
|
| 98 |
+
score += maxDpd.Value switch
|
| 99 |
+
{
|
| 100 |
+
0 => 0,
|
| 101 |
+
<= 30 => 10,
|
| 102 |
+
<= 60 => 20,
|
| 103 |
+
<= 90 => 30,
|
| 104 |
+
_ => 40
|
| 105 |
+
};
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// Critical flags
|
| 109 |
+
if (writtenOff) score = Math.Max(score, 85);
|
| 110 |
+
if (suitFiled) score = Math.Max(score, 90);
|
| 111 |
+
if (wilfulDefaulter) score = 100;
|
| 112 |
+
|
| 113 |
+
return Math.Clamp(score, 0, 100);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
private static decimal CalculateIncomeScore(DynamicDataContext data, Dictionary<string, decimal> weights)
|
| 117 |
+
{
|
| 118 |
+
var foir = GetDecimal(data, "ratios.foir");
|
| 119 |
+
var income = GetDecimal(data, "employment.monthly_income");
|
| 120 |
+
var emiObligation = GetDecimal(data, "bureau.total_emi_obligation");
|
| 121 |
+
|
| 122 |
+
decimal score = 30m;
|
| 123 |
+
|
| 124 |
+
if (foir.HasValue)
|
| 125 |
+
{
|
| 126 |
+
score = foir.Value switch
|
| 127 |
+
{
|
| 128 |
+
<= 30 => 10,
|
| 129 |
+
<= 40 => 20,
|
| 130 |
+
<= 50 => 35,
|
| 131 |
+
<= 60 => 55,
|
| 132 |
+
<= 70 => 70,
|
| 133 |
+
<= 80 => 85,
|
| 134 |
+
_ => 95
|
| 135 |
+
};
|
| 136 |
+
}
|
| 137 |
+
else if (income.HasValue && emiObligation.HasValue && income.Value > 0)
|
| 138 |
+
{
|
| 139 |
+
var computedFoir = (emiObligation.Value / income.Value) * 100;
|
| 140 |
+
score = computedFoir <= 40 ? 15 : computedFoir <= 60 ? 45 : 75;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
return Math.Clamp(score, 0, 100);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
private static decimal CalculateFiScore(DynamicDataContext data, Dictionary<string, decimal> weights)
|
| 147 |
+
{
|
| 148 |
+
var fiNegative = GetBool(data, "fi.negative");
|
| 149 |
+
var addressMatch = GetBool(data, "fi.address_match");
|
| 150 |
+
var mobileMatch = GetBool(data, "fi.mobile_match");
|
| 151 |
+
var fiVerified = GetBool(data, "fi.verified");
|
| 152 |
+
|
| 153 |
+
if (!fiVerified) return 40m; // unverified
|
| 154 |
+
|
| 155 |
+
decimal score = 10m;
|
| 156 |
+
if (fiNegative) score += 60;
|
| 157 |
+
if (!addressMatch) score += 20;
|
| 158 |
+
if (!mobileMatch) score += 10;
|
| 159 |
+
|
| 160 |
+
return Math.Clamp(score, 0, 100);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
private static decimal CalculateFraudScore(DynamicDataContext data, Dictionary<string, decimal> weights)
|
| 164 |
+
{
|
| 165 |
+
var fraudScore = GetDecimal(data, "fraud.score");
|
| 166 |
+
var blacklisted = GetBool(data, "fraud.blacklisted");
|
| 167 |
+
|
| 168 |
+
if (blacklisted) return 100;
|
| 169 |
+
return fraudScore.HasValue ? Math.Clamp(fraudScore.Value, 0, 100) : 20m;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
private static decimal CalculateVehicleScore(DynamicDataContext data, Dictionary<string, decimal> weights)
|
| 173 |
+
{
|
| 174 |
+
var vehicleAge = GetDecimal(data, "vehicle.age_years");
|
| 175 |
+
var ltv = GetDecimal(data, "ratios.ltv");
|
| 176 |
+
|
| 177 |
+
decimal score = 20m;
|
| 178 |
+
|
| 179 |
+
if (vehicleAge.HasValue)
|
| 180 |
+
{
|
| 181 |
+
score += vehicleAge.Value switch
|
| 182 |
+
{
|
| 183 |
+
<= 2 => 0,
|
| 184 |
+
<= 5 => 10,
|
| 185 |
+
<= 8 => 20,
|
| 186 |
+
<= 10 => 35,
|
| 187 |
+
_ => 50
|
| 188 |
+
};
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
if (ltv.HasValue)
|
| 192 |
+
{
|
| 193 |
+
score += ltv.Value switch
|
| 194 |
+
{
|
| 195 |
+
<= 70 => 0,
|
| 196 |
+
<= 80 => 10,
|
| 197 |
+
<= 90 => 20,
|
| 198 |
+
_ => 35
|
| 199 |
+
};
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
return Math.Clamp(score, 0, 100);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
private static decimal CalculateEmploymentScore(DynamicDataContext data, Dictionary<string, decimal> weights)
|
| 206 |
+
{
|
| 207 |
+
var vintage = GetDecimal(data, "employment.vintage_months");
|
| 208 |
+
var empType = data.GetValue("employment.type")?.ToString();
|
| 209 |
+
|
| 210 |
+
decimal score = 30m;
|
| 211 |
+
|
| 212 |
+
if (vintage.HasValue)
|
| 213 |
+
{
|
| 214 |
+
score = vintage.Value switch
|
| 215 |
+
{
|
| 216 |
+
>= 60 => 5,
|
| 217 |
+
>= 36 => 15,
|
| 218 |
+
>= 24 => 25,
|
| 219 |
+
>= 12 => 40,
|
| 220 |
+
>= 6 => 60,
|
| 221 |
+
_ => 75
|
| 222 |
+
};
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Salaried lower risk than self-employed
|
| 226 |
+
if (empType?.ToLower() == "salaried") score = Math.Max(score - 10, 0);
|
| 227 |
+
|
| 228 |
+
return Math.Clamp(score, 0, 100);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
private static decimal CalculateRulePenalty(List<RuleEvaluationResult> results)
|
| 232 |
+
{
|
| 233 |
+
int matchedCount = results.Count(r => r.IsMatched);
|
| 234 |
+
int totalCount = results.Count;
|
| 235 |
+
if (totalCount == 0) return 0;
|
| 236 |
+
|
| 237 |
+
return Math.Round((decimal)matchedCount / totalCount * 100, 2);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
private static RiskCategory ClassifyRisk(decimal score) => score switch
|
| 241 |
+
{
|
| 242 |
+
<= 25 => RiskCategory.Low,
|
| 243 |
+
<= 50 => RiskCategory.Medium,
|
| 244 |
+
<= 75 => RiskCategory.High,
|
| 245 |
+
_ => RiskCategory.Critical
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
private static TrafficLight ScoreToTrafficLight(decimal score) => score switch
|
| 249 |
+
{
|
| 250 |
+
<= 40 => TrafficLight.Green,
|
| 251 |
+
<= 65 => TrafficLight.Amber,
|
| 252 |
+
_ => TrafficLight.Red
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
private static decimal? GetDecimal(DynamicDataContext data, string path)
|
| 256 |
+
{
|
| 257 |
+
var val = data.GetValue(path);
|
| 258 |
+
if (val == null) return null;
|
| 259 |
+
if (decimal.TryParse(val.ToString(), out var d)) return d;
|
| 260 |
+
return null;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
private static bool GetBool(DynamicDataContext data, string path)
|
| 264 |
+
{
|
| 265 |
+
var val = data.GetValue(path);
|
| 266 |
+
return val is true or "true" or 1;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
private static decimal GetDefaultWeight(string component) => component switch
|
| 270 |
+
{
|
| 271 |
+
"bureau" => 30,
|
| 272 |
+
"income" => 25,
|
| 273 |
+
"fi" => 15,
|
| 274 |
+
"fraud" => 15,
|
| 275 |
+
"vehicle" => 10,
|
| 276 |
+
"employment" => 10,
|
| 277 |
+
"rule_penalty" => 5,
|
| 278 |
+
_ => 10
|
| 279 |
+
};
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
public interface IRiskWeightRepository
|
| 283 |
+
{
|
| 284 |
+
Task<Dictionary<string, decimal>> GetWeightsAsync(Guid tenantId, string? productCode = null);
|
| 285 |
+
}
|
RuleBuilder.tsx
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useCallback } from 'react'
|
| 4 |
+
import { v4 as uuidv4 } from 'uuid'
|
| 5 |
+
import {
|
| 6 |
+
DndContext,
|
| 7 |
+
DragEndEvent,
|
| 8 |
+
closestCenter,
|
| 9 |
+
PointerSensor,
|
| 10 |
+
useSensor,
|
| 11 |
+
useSensors,
|
| 12 |
+
} from '@dnd-kit/core'
|
| 13 |
+
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
| 14 |
+
import type {
|
| 15 |
+
ConditionGroup,
|
| 16 |
+
ConditionNode,
|
| 17 |
+
RuleAction,
|
| 18 |
+
RuleDefinition,
|
| 19 |
+
LogicalOperator,
|
| 20 |
+
ComparisonOperator,
|
| 21 |
+
ActionType,
|
| 22 |
+
FieldCatalogEntry,
|
| 23 |
+
} from '@/types'
|
| 24 |
+
import { ConditionGroupEditor } from './ConditionGroupEditor'
|
| 25 |
+
import { ActionEditor } from './ActionEditor'
|
| 26 |
+
import { RulePreview } from './RulePreview'
|
| 27 |
+
|
| 28 |
+
interface RuleBuilderProps {
|
| 29 |
+
initialDefinition?: RuleDefinition
|
| 30 |
+
fieldCatalog: FieldCatalogEntry[]
|
| 31 |
+
onChange: (definition: RuleDefinition) => void
|
| 32 |
+
readOnly?: boolean
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const defaultConditionGroup = (): ConditionGroup => ({
|
| 36 |
+
id: uuidv4(),
|
| 37 |
+
operator: 'AND',
|
| 38 |
+
rules: [],
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
const defaultDefinition = (): RuleDefinition => ({
|
| 42 |
+
conditions: defaultConditionGroup(),
|
| 43 |
+
actions: [],
|
| 44 |
+
metadata: { executionOrder: 0, stopOnMatch: false, errorHandling: 'SKIP' },
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
export function RuleBuilder({
|
| 48 |
+
initialDefinition,
|
| 49 |
+
fieldCatalog,
|
| 50 |
+
onChange,
|
| 51 |
+
readOnly = false,
|
| 52 |
+
}: RuleBuilderProps) {
|
| 53 |
+
const [definition, setDefinition] = useState<RuleDefinition>(
|
| 54 |
+
initialDefinition ?? defaultDefinition()
|
| 55 |
+
)
|
| 56 |
+
const [activeTab, setActiveTab] = useState<'builder' | 'preview' | 'json'>('builder')
|
| 57 |
+
|
| 58 |
+
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
| 59 |
+
|
| 60 |
+
const updateDefinition = useCallback(
|
| 61 |
+
(updater: (prev: RuleDefinition) => RuleDefinition) => {
|
| 62 |
+
setDefinition((prev) => {
|
| 63 |
+
const next = updater(prev)
|
| 64 |
+
onChange(next)
|
| 65 |
+
return next
|
| 66 |
+
})
|
| 67 |
+
},
|
| 68 |
+
[onChange]
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
const handleConditionsChange = useCallback(
|
| 72 |
+
(conditions: ConditionGroup) => {
|
| 73 |
+
updateDefinition((prev) => ({ ...prev, conditions }))
|
| 74 |
+
},
|
| 75 |
+
[updateDefinition]
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
const handleActionsChange = useCallback(
|
| 79 |
+
(actions: RuleAction[]) => {
|
| 80 |
+
updateDefinition((prev) => ({ ...prev, actions }))
|
| 81 |
+
},
|
| 82 |
+
[updateDefinition]
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
const addAction = () => {
|
| 86 |
+
const newAction: RuleAction = {
|
| 87 |
+
id: uuidv4(),
|
| 88 |
+
type: 'SET_DECISION',
|
| 89 |
+
value: 'REJECT',
|
| 90 |
+
}
|
| 91 |
+
handleActionsChange([...definition.actions, newAction])
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
return (
|
| 95 |
+
<div className="flex flex-col h-full bg-gray-50">
|
| 96 |
+
{/* Tabs */}
|
| 97 |
+
<div className="flex border-b border-gray-200 bg-white px-6">
|
| 98 |
+
{(['builder', 'preview', 'json'] as const).map((tab) => (
|
| 99 |
+
<button
|
| 100 |
+
key={tab}
|
| 101 |
+
onClick={() => setActiveTab(tab)}
|
| 102 |
+
className={`py-3 px-6 text-sm font-medium capitalize border-b-2 transition-colors ${
|
| 103 |
+
activeTab === tab
|
| 104 |
+
? 'border-blue-600 text-blue-600'
|
| 105 |
+
: 'border-transparent text-gray-500 hover:text-gray-700'
|
| 106 |
+
}`}
|
| 107 |
+
>
|
| 108 |
+
{tab === 'json' ? 'JSON View' : tab.charAt(0).toUpperCase() + tab.slice(1)}
|
| 109 |
+
</button>
|
| 110 |
+
))}
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{/* Content */}
|
| 114 |
+
<div className="flex-1 overflow-auto p-6">
|
| 115 |
+
{activeTab === 'builder' && (
|
| 116 |
+
<DndContext sensors={sensors} collisionDetection={closestCenter}>
|
| 117 |
+
<div className="max-w-4xl mx-auto space-y-6">
|
| 118 |
+
{/* CONDITIONS */}
|
| 119 |
+
<section className="bg-white rounded-xl shadow-sm border border-gray-200">
|
| 120 |
+
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
| 121 |
+
<div className="flex items-center gap-3">
|
| 122 |
+
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center">
|
| 123 |
+
<span className="text-blue-600 text-sm font-bold">IF</span>
|
| 124 |
+
</div>
|
| 125 |
+
<h2 className="text-base font-semibold text-gray-900">Conditions</h2>
|
| 126 |
+
<span className="text-xs text-gray-400">
|
| 127 |
+
Define when this rule should trigger
|
| 128 |
+
</span>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
<div className="p-6">
|
| 132 |
+
<ConditionGroupEditor
|
| 133 |
+
group={definition.conditions}
|
| 134 |
+
fieldCatalog={fieldCatalog}
|
| 135 |
+
onChange={handleConditionsChange}
|
| 136 |
+
readOnly={readOnly}
|
| 137 |
+
depth={0}
|
| 138 |
+
/>
|
| 139 |
+
</div>
|
| 140 |
+
</section>
|
| 141 |
+
|
| 142 |
+
{/* ACTIONS */}
|
| 143 |
+
<section className="bg-white rounded-xl shadow-sm border border-gray-200">
|
| 144 |
+
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
| 145 |
+
<div className="flex items-center gap-3">
|
| 146 |
+
<div className="w-8 h-8 rounded-lg bg-emerald-100 flex items-center justify-center">
|
| 147 |
+
<span className="text-emerald-600 text-sm font-bold">THEN</span>
|
| 148 |
+
</div>
|
| 149 |
+
<h2 className="text-base font-semibold text-gray-900">Actions</h2>
|
| 150 |
+
<span className="text-xs text-gray-400">What happens when conditions match</span>
|
| 151 |
+
</div>
|
| 152 |
+
{!readOnly && (
|
| 153 |
+
<button
|
| 154 |
+
onClick={addAction}
|
| 155 |
+
className="flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
| 156 |
+
>
|
| 157 |
+
<span className="text-lg leading-none">+</span> Add Action
|
| 158 |
+
</button>
|
| 159 |
+
)}
|
| 160 |
+
</div>
|
| 161 |
+
<div className="p-6 space-y-3">
|
| 162 |
+
{definition.actions.length === 0 ? (
|
| 163 |
+
<div className="text-center py-8 text-gray-400">
|
| 164 |
+
<p className="text-sm">No actions defined yet.</p>
|
| 165 |
+
{!readOnly && (
|
| 166 |
+
<button
|
| 167 |
+
onClick={addAction}
|
| 168 |
+
className="mt-2 text-sm text-blue-600 hover:text-blue-700"
|
| 169 |
+
>
|
| 170 |
+
+ Add your first action
|
| 171 |
+
</button>
|
| 172 |
+
)}
|
| 173 |
+
</div>
|
| 174 |
+
) : (
|
| 175 |
+
definition.actions.map((action, idx) => (
|
| 176 |
+
<ActionEditor
|
| 177 |
+
key={action.id}
|
| 178 |
+
action={action}
|
| 179 |
+
index={idx}
|
| 180 |
+
readOnly={readOnly}
|
| 181 |
+
onChange={(updated) => {
|
| 182 |
+
const next = [...definition.actions]
|
| 183 |
+
next[idx] = updated
|
| 184 |
+
handleActionsChange(next)
|
| 185 |
+
}}
|
| 186 |
+
onDelete={() => {
|
| 187 |
+
handleActionsChange(definition.actions.filter((_, i) => i !== idx))
|
| 188 |
+
}}
|
| 189 |
+
/>
|
| 190 |
+
))
|
| 191 |
+
)}
|
| 192 |
+
</div>
|
| 193 |
+
</section>
|
| 194 |
+
|
| 195 |
+
{/* METADATA */}
|
| 196 |
+
<section className="bg-white rounded-xl shadow-sm border border-gray-200">
|
| 197 |
+
<div className="px-6 py-4 border-b border-gray-100">
|
| 198 |
+
<h2 className="text-base font-semibold text-gray-900">Rule Settings</h2>
|
| 199 |
+
</div>
|
| 200 |
+
<div className="p-6 grid grid-cols-3 gap-4">
|
| 201 |
+
<label className="flex flex-col gap-1">
|
| 202 |
+
<span className="text-xs font-medium text-gray-600">Execution Order</span>
|
| 203 |
+
<input
|
| 204 |
+
type="number"
|
| 205 |
+
value={definition.metadata?.executionOrder ?? 0}
|
| 206 |
+
disabled={readOnly}
|
| 207 |
+
onChange={(e) =>
|
| 208 |
+
updateDefinition((prev) => ({
|
| 209 |
+
...prev,
|
| 210 |
+
metadata: { ...prev.metadata, executionOrder: Number(e.target.value) },
|
| 211 |
+
}))
|
| 212 |
+
}
|
| 213 |
+
className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 214 |
+
/>
|
| 215 |
+
</label>
|
| 216 |
+
<label className="flex flex-col gap-1">
|
| 217 |
+
<span className="text-xs font-medium text-gray-600">Error Handling</span>
|
| 218 |
+
<select
|
| 219 |
+
value={definition.metadata?.errorHandling ?? 'SKIP'}
|
| 220 |
+
disabled={readOnly}
|
| 221 |
+
onChange={(e) =>
|
| 222 |
+
updateDefinition((prev) => ({
|
| 223 |
+
...prev,
|
| 224 |
+
metadata: {
|
| 225 |
+
...prev.metadata,
|
| 226 |
+
errorHandling: e.target.value as 'SKIP' | 'FAIL' | 'USE_DEFAULT',
|
| 227 |
+
},
|
| 228 |
+
}))
|
| 229 |
+
}
|
| 230 |
+
className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500"
|
| 231 |
+
>
|
| 232 |
+
<option value="SKIP">Skip on Error</option>
|
| 233 |
+
<option value="FAIL">Fail on Error</option>
|
| 234 |
+
<option value="USE_DEFAULT">Use Default</option>
|
| 235 |
+
</select>
|
| 236 |
+
</label>
|
| 237 |
+
<label className="flex items-center gap-3 mt-5">
|
| 238 |
+
<input
|
| 239 |
+
type="checkbox"
|
| 240 |
+
checked={definition.metadata?.stopOnMatch ?? false}
|
| 241 |
+
disabled={readOnly}
|
| 242 |
+
onChange={(e) =>
|
| 243 |
+
updateDefinition((prev) => ({
|
| 244 |
+
...prev,
|
| 245 |
+
metadata: { ...prev.metadata, stopOnMatch: e.target.checked },
|
| 246 |
+
}))
|
| 247 |
+
}
|
| 248 |
+
className="w-4 h-4 rounded border-gray-300 text-blue-600"
|
| 249 |
+
/>
|
| 250 |
+
<span className="text-sm font-medium text-gray-700">Stop on Match</span>
|
| 251 |
+
</label>
|
| 252 |
+
</div>
|
| 253 |
+
</section>
|
| 254 |
+
</div>
|
| 255 |
+
</DndContext>
|
| 256 |
+
)}
|
| 257 |
+
|
| 258 |
+
{activeTab === 'preview' && (
|
| 259 |
+
<RulePreview definition={definition} fieldCatalog={fieldCatalog} />
|
| 260 |
+
)}
|
| 261 |
+
|
| 262 |
+
{activeTab === 'json' && (
|
| 263 |
+
<div className="max-w-4xl mx-auto">
|
| 264 |
+
<div className="bg-gray-900 rounded-xl p-6 overflow-auto">
|
| 265 |
+
<pre className="text-green-400 text-sm font-mono whitespace-pre-wrap">
|
| 266 |
+
{JSON.stringify(definition, null, 2)}
|
| 267 |
+
</pre>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
)}
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
)
|
| 274 |
+
}
|
RuleDesignerController.cs
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.AspNetCore.Authorization;
|
| 2 |
+
using Microsoft.AspNetCore.Mvc;
|
| 3 |
+
using OptimAI.BRE.RuleEngine.Api;
|
| 4 |
+
using OptimAI.BRE.Shared.Domain;
|
| 5 |
+
|
| 6 |
+
namespace OptimAI.BRE.RuleDesigner.Api;
|
| 7 |
+
|
| 8 |
+
[ApiController]
|
| 9 |
+
[Route("api/v1/rules")]
|
| 10 |
+
[Authorize]
|
| 11 |
+
public sealed class RuleDesignerController : ControllerBase
|
| 12 |
+
{
|
| 13 |
+
private readonly IRuleRepository _ruleRepo;
|
| 14 |
+
private readonly IRuleVersionRepository _versionRepo;
|
| 15 |
+
private readonly IRuleApprovalService _approvalService;
|
| 16 |
+
private readonly IAuditService _auditService;
|
| 17 |
+
private readonly ITenantContextAccessor _tenantAccessor;
|
| 18 |
+
|
| 19 |
+
public RuleDesignerController(
|
| 20 |
+
IRuleRepository ruleRepo,
|
| 21 |
+
IRuleVersionRepository versionRepo,
|
| 22 |
+
IRuleApprovalService approvalService,
|
| 23 |
+
IAuditService auditService,
|
| 24 |
+
ITenantContextAccessor tenantAccessor)
|
| 25 |
+
{
|
| 26 |
+
_ruleRepo = ruleRepo;
|
| 27 |
+
_versionRepo = versionRepo;
|
| 28 |
+
_approvalService = approvalService;
|
| 29 |
+
_auditService = auditService;
|
| 30 |
+
_tenantAccessor = tenantAccessor;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
[HttpGet]
|
| 34 |
+
[ProducesResponseType(typeof(PagedResponse<RuleSummaryDto>), 200)]
|
| 35 |
+
public async Task<IActionResult> GetRules(
|
| 36 |
+
[FromQuery] int page = 1,
|
| 37 |
+
[FromQuery] int pageSize = 20,
|
| 38 |
+
[FromQuery] string? category = null,
|
| 39 |
+
[FromQuery] string? ruleType = null,
|
| 40 |
+
[FromQuery] string? status = null,
|
| 41 |
+
[FromQuery] string? search = null,
|
| 42 |
+
CancellationToken ct = default)
|
| 43 |
+
{
|
| 44 |
+
var result = await _ruleRepo.GetPagedAsync(new RuleQuery
|
| 45 |
+
{
|
| 46 |
+
TenantId = _tenantAccessor.TenantId,
|
| 47 |
+
Page = page,
|
| 48 |
+
PageSize = Math.Min(pageSize, 100),
|
| 49 |
+
Category = category,
|
| 50 |
+
RuleType = ruleType,
|
| 51 |
+
Status = status,
|
| 52 |
+
Search = search
|
| 53 |
+
}, ct);
|
| 54 |
+
|
| 55 |
+
return Ok(result);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
[HttpGet("{id:guid}")]
|
| 59 |
+
[ProducesResponseType(typeof(RuleDetailDto), 200)]
|
| 60 |
+
[ProducesResponseType(404)]
|
| 61 |
+
public async Task<IActionResult> GetRule(Guid id, CancellationToken ct)
|
| 62 |
+
{
|
| 63 |
+
var rule = await _ruleRepo.GetByIdAsync(id, _tenantAccessor.TenantId, ct);
|
| 64 |
+
if (rule == null) return NotFound();
|
| 65 |
+
return Ok(MapToDetailDto(rule));
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
[HttpPost]
|
| 69 |
+
[Authorize(Policy = "RuleWrite")]
|
| 70 |
+
[ProducesResponseType(typeof(RuleDetailDto), 201)]
|
| 71 |
+
public async Task<IActionResult> CreateRule([FromBody] CreateRuleRequest request, CancellationToken ct)
|
| 72 |
+
{
|
| 73 |
+
var rule = new Rule
|
| 74 |
+
{
|
| 75 |
+
TenantId = _tenantAccessor.TenantId,
|
| 76 |
+
RuleCode = request.RuleCode ?? GenerateRuleCode(request.RuleName),
|
| 77 |
+
RuleName = request.RuleName,
|
| 78 |
+
Description = request.Description,
|
| 79 |
+
CategoryId = request.CategoryId,
|
| 80 |
+
RuleType = Enum.Parse<RuleType>(request.RuleType, true),
|
| 81 |
+
Priority = request.Priority,
|
| 82 |
+
Tags = request.Tags ?? new(),
|
| 83 |
+
Status = RuleStatus.Draft,
|
| 84 |
+
CreatedBy = _tenantAccessor.UserId
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
var version = new RuleVersion
|
| 88 |
+
{
|
| 89 |
+
TenantId = _tenantAccessor.TenantId,
|
| 90 |
+
VersionNumber = 1,
|
| 91 |
+
VersionLabel = "v1.0",
|
| 92 |
+
RuleDefinition = request.RuleDefinition,
|
| 93 |
+
Status = RuleStatus.Draft,
|
| 94 |
+
IsCurrent = true,
|
| 95 |
+
CreatedBy = _tenantAccessor.UserId
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
var created = await _ruleRepo.CreateWithVersionAsync(rule, version, ct);
|
| 99 |
+
|
| 100 |
+
await _auditService.LogAsync(new AuditLog
|
| 101 |
+
{
|
| 102 |
+
TenantId = _tenantAccessor.TenantId,
|
| 103 |
+
UserId = _tenantAccessor.UserId,
|
| 104 |
+
Action = "RULE_CREATED",
|
| 105 |
+
EntityType = "RULE",
|
| 106 |
+
EntityId = created.Id.ToString(),
|
| 107 |
+
NewValues = new Dictionary<string, object> { ["ruleCode"] = created.RuleCode, ["ruleName"] = created.RuleName }
|
| 108 |
+
}, ct);
|
| 109 |
+
|
| 110 |
+
return CreatedAtAction(nameof(GetRule), new { id = created.Id }, MapToDetailDto(created));
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
[HttpPut("{id:guid}")]
|
| 114 |
+
[Authorize(Policy = "RuleWrite")]
|
| 115 |
+
[ProducesResponseType(typeof(RuleDetailDto), 200)]
|
| 116 |
+
[ProducesResponseType(404)]
|
| 117 |
+
public async Task<IActionResult> UpdateRule(Guid id, [FromBody] UpdateRuleRequest request, CancellationToken ct)
|
| 118 |
+
{
|
| 119 |
+
var rule = await _ruleRepo.GetByIdAsync(id, _tenantAccessor.TenantId, ct);
|
| 120 |
+
if (rule == null) return NotFound();
|
| 121 |
+
|
| 122 |
+
var oldValues = new Dictionary<string, object> { ["ruleName"] = rule.RuleName, ["status"] = rule.Status.ToString() };
|
| 123 |
+
|
| 124 |
+
// Create new version
|
| 125 |
+
var latestVersion = await _versionRepo.GetLatestAsync(id, ct);
|
| 126 |
+
var newVersionNumber = (latestVersion?.VersionNumber ?? 0) + 1;
|
| 127 |
+
|
| 128 |
+
var newVersion = new RuleVersion
|
| 129 |
+
{
|
| 130 |
+
TenantId = _tenantAccessor.TenantId,
|
| 131 |
+
RuleId = id,
|
| 132 |
+
VersionNumber = newVersionNumber,
|
| 133 |
+
VersionLabel = $"v{newVersionNumber}.0",
|
| 134 |
+
RuleDefinition = request.RuleDefinition,
|
| 135 |
+
ChangeSummary = request.ChangeSummary,
|
| 136 |
+
Status = RuleStatus.Draft,
|
| 137 |
+
IsCurrent = false,
|
| 138 |
+
CreatedBy = _tenantAccessor.UserId
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
rule.RuleName = request.RuleName ?? rule.RuleName;
|
| 142 |
+
rule.Description = request.Description ?? rule.Description;
|
| 143 |
+
rule.Priority = request.Priority ?? rule.Priority;
|
| 144 |
+
rule.Tags = request.Tags ?? rule.Tags;
|
| 145 |
+
rule.Status = RuleStatus.Draft;
|
| 146 |
+
rule.IsPublished = false;
|
| 147 |
+
|
| 148 |
+
await _ruleRepo.UpdateWithNewVersionAsync(rule, newVersion, ct);
|
| 149 |
+
|
| 150 |
+
await _auditService.LogAsync(new AuditLog
|
| 151 |
+
{
|
| 152 |
+
TenantId = _tenantAccessor.TenantId,
|
| 153 |
+
UserId = _tenantAccessor.UserId,
|
| 154 |
+
Action = "RULE_UPDATED",
|
| 155 |
+
EntityType = "RULE",
|
| 156 |
+
EntityId = id.ToString(),
|
| 157 |
+
OldValues = oldValues,
|
| 158 |
+
NewValues = new Dictionary<string, object>
|
| 159 |
+
{
|
| 160 |
+
["ruleName"] = rule.RuleName,
|
| 161 |
+
["versionNumber"] = newVersionNumber,
|
| 162 |
+
["changeSummary"] = request.ChangeSummary ?? ""
|
| 163 |
+
}
|
| 164 |
+
}, ct);
|
| 165 |
+
|
| 166 |
+
var updated = await _ruleRepo.GetByIdAsync(id, _tenantAccessor.TenantId, ct);
|
| 167 |
+
return Ok(MapToDetailDto(updated!));
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
[HttpPost("{id:guid}/submit-for-approval")]
|
| 171 |
+
[Authorize(Policy = "RuleWrite")]
|
| 172 |
+
public async Task<IActionResult> SubmitForApproval(Guid id, [FromBody] SubmitApprovalRequest request, CancellationToken ct)
|
| 173 |
+
{
|
| 174 |
+
var rule = await _ruleRepo.GetByIdAsync(id, _tenantAccessor.TenantId, ct);
|
| 175 |
+
if (rule == null) return NotFound();
|
| 176 |
+
|
| 177 |
+
await _approvalService.SubmitForApprovalAsync(id, rule.CurrentVersionId!.Value, _tenantAccessor.TenantId, _tenantAccessor.UserId, request.Comments, ct);
|
| 178 |
+
|
| 179 |
+
return Ok(new { message = "Rule submitted for approval", status = "PENDING_APPROVAL" });
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
[HttpPost("{id:guid}/approve")]
|
| 183 |
+
[Authorize(Policy = "RuleApprove")]
|
| 184 |
+
public async Task<IActionResult> ApproveRule(Guid id, [FromBody] ApproveRuleRequest request, CancellationToken ct)
|
| 185 |
+
{
|
| 186 |
+
await _approvalService.ApproveAsync(id, _tenantAccessor.TenantId, _tenantAccessor.UserId, request.Comments, ct);
|
| 187 |
+
|
| 188 |
+
await _auditService.LogAsync(new AuditLog
|
| 189 |
+
{
|
| 190 |
+
TenantId = _tenantAccessor.TenantId,
|
| 191 |
+
UserId = _tenantAccessor.UserId,
|
| 192 |
+
Action = "RULE_APPROVED",
|
| 193 |
+
EntityType = "RULE",
|
| 194 |
+
EntityId = id.ToString()
|
| 195 |
+
}, ct);
|
| 196 |
+
|
| 197 |
+
return Ok(new { message = "Rule approved successfully" });
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
[HttpPost("{id:guid}/reject")]
|
| 201 |
+
[Authorize(Policy = "RuleApprove")]
|
| 202 |
+
public async Task<IActionResult> RejectRule(Guid id, [FromBody] RejectRuleRequest request, CancellationToken ct)
|
| 203 |
+
{
|
| 204 |
+
await _approvalService.RejectAsync(id, _tenantAccessor.TenantId, _tenantAccessor.UserId, request.Comments, ct);
|
| 205 |
+
return Ok(new { message = "Rule rejected" });
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
[HttpPost("{id:guid}/publish")]
|
| 209 |
+
[Authorize(Policy = "RulePublish")]
|
| 210 |
+
public async Task<IActionResult> PublishRule(Guid id, CancellationToken ct)
|
| 211 |
+
{
|
| 212 |
+
var rule = await _ruleRepo.GetByIdAsync(id, _tenantAccessor.TenantId, ct);
|
| 213 |
+
if (rule == null) return NotFound();
|
| 214 |
+
if (rule.Status != RuleStatus.Approved) return BadRequest(new { error = "Rule must be approved before publishing" });
|
| 215 |
+
|
| 216 |
+
await _ruleRepo.PublishAsync(id, _tenantAccessor.TenantId, _tenantAccessor.UserId, ct);
|
| 217 |
+
|
| 218 |
+
await _auditService.LogAsync(new AuditLog
|
| 219 |
+
{
|
| 220 |
+
TenantId = _tenantAccessor.TenantId,
|
| 221 |
+
UserId = _tenantAccessor.UserId,
|
| 222 |
+
Action = "RULE_PUBLISHED",
|
| 223 |
+
EntityType = "RULE",
|
| 224 |
+
EntityId = id.ToString()
|
| 225 |
+
}, ct);
|
| 226 |
+
|
| 227 |
+
return Ok(new { message = "Rule published to production" });
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
[HttpPost("{id:guid}/clone")]
|
| 231 |
+
[Authorize(Policy = "RuleWrite")]
|
| 232 |
+
public async Task<IActionResult> CloneRule(Guid id, [FromBody] CloneRuleRequest request, CancellationToken ct)
|
| 233 |
+
{
|
| 234 |
+
var original = await _ruleRepo.GetByIdAsync(id, _tenantAccessor.TenantId, ct);
|
| 235 |
+
if (original == null) return NotFound();
|
| 236 |
+
|
| 237 |
+
var cloned = await _ruleRepo.CloneAsync(id, _tenantAccessor.TenantId, _tenantAccessor.UserId, request.NewRuleCode, request.NewRuleName, ct);
|
| 238 |
+
|
| 239 |
+
return CreatedAtAction(nameof(GetRule), new { id = cloned.Id }, MapToDetailDto(cloned));
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
[HttpPatch("{id:guid}/toggle")]
|
| 243 |
+
[Authorize(Policy = "RuleWrite")]
|
| 244 |
+
public async Task<IActionResult> ToggleRule(Guid id, CancellationToken ct)
|
| 245 |
+
{
|
| 246 |
+
var rule = await _ruleRepo.GetByIdAsync(id, _tenantAccessor.TenantId, ct);
|
| 247 |
+
if (rule == null) return NotFound();
|
| 248 |
+
|
| 249 |
+
await _ruleRepo.ToggleActiveAsync(id, _tenantAccessor.TenantId, ct);
|
| 250 |
+
return Ok(new { message = $"Rule {(rule.IsActive ? "disabled" : "enabled")}", isActive = !rule.IsActive });
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
[HttpGet("{id:guid}/versions")]
|
| 254 |
+
[ProducesResponseType(typeof(List<RuleVersionSummaryDto>), 200)]
|
| 255 |
+
public async Task<IActionResult> GetVersions(Guid id, CancellationToken ct)
|
| 256 |
+
{
|
| 257 |
+
var versions = await _versionRepo.GetAllAsync(id, _tenantAccessor.TenantId, ct);
|
| 258 |
+
return Ok(versions.Select(v => new RuleVersionSummaryDto
|
| 259 |
+
{
|
| 260 |
+
Id = v.Id,
|
| 261 |
+
VersionNumber = v.VersionNumber,
|
| 262 |
+
VersionLabel = v.VersionLabel ?? $"v{v.VersionNumber}.0",
|
| 263 |
+
Status = v.Status.ToString(),
|
| 264 |
+
ChangeSummary = v.ChangeSummary,
|
| 265 |
+
IsCurrent = v.IsCurrent,
|
| 266 |
+
CreatedAt = v.CreatedAt,
|
| 267 |
+
ApprovedAt = v.ApprovedAt,
|
| 268 |
+
PublishedAt = v.PublishedAt
|
| 269 |
+
}).ToList());
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
[HttpPut("{id:guid}/scopes")]
|
| 273 |
+
[Authorize(Policy = "RuleWrite")]
|
| 274 |
+
public async Task<IActionResult> UpdateScopes(Guid id, [FromBody] UpdateScopesRequest request, CancellationToken ct)
|
| 275 |
+
{
|
| 276 |
+
await _ruleRepo.UpdateScopesAsync(id, _tenantAccessor.TenantId, request.Scopes, ct);
|
| 277 |
+
return Ok(new { message = "Rule scopes updated" });
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
private static string GenerateRuleCode(string ruleName)
|
| 281 |
+
{
|
| 282 |
+
return ruleName.ToLower()
|
| 283 |
+
.Replace(" ", "_")
|
| 284 |
+
.Replace("-", "_")
|
| 285 |
+
.Replace("/", "_")
|
| 286 |
+
[..Math.Min(ruleName.Length, 80)];
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
private static RuleDetailDto MapToDetailDto(Rule rule) => new()
|
| 290 |
+
{
|
| 291 |
+
Id = rule.Id,
|
| 292 |
+
RuleCode = rule.RuleCode,
|
| 293 |
+
RuleName = rule.RuleName,
|
| 294 |
+
Description = rule.Description,
|
| 295 |
+
CategoryId = rule.CategoryId,
|
| 296 |
+
RuleType = rule.RuleType.ToString(),
|
| 297 |
+
Priority = rule.Priority,
|
| 298 |
+
IsActive = rule.IsActive,
|
| 299 |
+
IsPublished = rule.IsPublished,
|
| 300 |
+
Status = rule.Status.ToString(),
|
| 301 |
+
Tags = rule.Tags,
|
| 302 |
+
CurrentVersion = rule.CurrentVersion != null ? new RuleVersionDto
|
| 303 |
+
{
|
| 304 |
+
Id = rule.CurrentVersion.Id,
|
| 305 |
+
VersionNumber = rule.CurrentVersion.VersionNumber,
|
| 306 |
+
VersionLabel = rule.CurrentVersion.VersionLabel ?? $"v{rule.CurrentVersion.VersionNumber}.0",
|
| 307 |
+
RuleDefinition = rule.CurrentVersion.RuleDefinition,
|
| 308 |
+
Status = rule.CurrentVersion.Status.ToString(),
|
| 309 |
+
CreatedAt = rule.CurrentVersion.CreatedAt
|
| 310 |
+
} : null,
|
| 311 |
+
Scopes = rule.Scopes.Select(s => new RuleScopeDto
|
| 312 |
+
{
|
| 313 |
+
ScopeType = s.ScopeType.ToString(),
|
| 314 |
+
ScopeValue = s.ScopeValue,
|
| 315 |
+
IsExcluded = s.IsExcluded
|
| 316 |
+
}).ToList(),
|
| 317 |
+
CreatedAt = rule.CreatedAt,
|
| 318 |
+
UpdatedAt = rule.UpdatedAt
|
| 319 |
+
};
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// ============================================================
|
| 323 |
+
// DTOs
|
| 324 |
+
// ============================================================
|
| 325 |
+
|
| 326 |
+
public record RuleSummaryDto
|
| 327 |
+
{
|
| 328 |
+
public Guid Id { get; init; }
|
| 329 |
+
public string RuleCode { get; init; } = default!;
|
| 330 |
+
public string RuleName { get; init; } = default!;
|
| 331 |
+
public string RuleType { get; init; } = default!;
|
| 332 |
+
public string Status { get; init; } = default!;
|
| 333 |
+
public bool IsActive { get; init; }
|
| 334 |
+
public bool IsPublished { get; init; }
|
| 335 |
+
public int Priority { get; init; }
|
| 336 |
+
public List<string> Tags { get; init; } = new();
|
| 337 |
+
public int VersionCount { get; init; }
|
| 338 |
+
public DateTime CreatedAt { get; init; }
|
| 339 |
+
public DateTime UpdatedAt { get; init; }
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
public record RuleDetailDto : RuleSummaryDto
|
| 343 |
+
{
|
| 344 |
+
public string? Description { get; init; }
|
| 345 |
+
public Guid? CategoryId { get; init; }
|
| 346 |
+
public RuleVersionDto? CurrentVersion { get; init; }
|
| 347 |
+
public List<RuleScopeDto> Scopes { get; init; } = new();
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
public record RuleVersionDto
|
| 351 |
+
{
|
| 352 |
+
public Guid Id { get; init; }
|
| 353 |
+
public int VersionNumber { get; init; }
|
| 354 |
+
public string VersionLabel { get; init; } = default!;
|
| 355 |
+
public RuleDefinition RuleDefinition { get; init; } = default!;
|
| 356 |
+
public string Status { get; init; } = default!;
|
| 357 |
+
public DateTime CreatedAt { get; init; }
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
public record RuleVersionSummaryDto
|
| 361 |
+
{
|
| 362 |
+
public Guid Id { get; init; }
|
| 363 |
+
public int VersionNumber { get; init; }
|
| 364 |
+
public string VersionLabel { get; init; } = default!;
|
| 365 |
+
public string Status { get; init; } = default!;
|
| 366 |
+
public string? ChangeSummary { get; init; }
|
| 367 |
+
public bool IsCurrent { get; init; }
|
| 368 |
+
public DateTime CreatedAt { get; init; }
|
| 369 |
+
public DateTime? ApprovedAt { get; init; }
|
| 370 |
+
public DateTime? PublishedAt { get; init; }
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
public record RuleScopeDto
|
| 374 |
+
{
|
| 375 |
+
public string ScopeType { get; init; } = default!;
|
| 376 |
+
public string ScopeValue { get; init; } = default!;
|
| 377 |
+
public bool IsExcluded { get; init; }
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
public record CreateRuleRequest
|
| 381 |
+
{
|
| 382 |
+
public string RuleName { get; init; } = default!;
|
| 383 |
+
public string? RuleCode { get; init; }
|
| 384 |
+
public string? Description { get; init; }
|
| 385 |
+
public Guid? CategoryId { get; init; }
|
| 386 |
+
public string RuleType { get; init; } = default!;
|
| 387 |
+
public int Priority { get; init; } = 100;
|
| 388 |
+
public List<string>? Tags { get; init; }
|
| 389 |
+
public RuleDefinition RuleDefinition { get; init; } = default!;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
public record UpdateRuleRequest
|
| 393 |
+
{
|
| 394 |
+
public string? RuleName { get; init; }
|
| 395 |
+
public string? Description { get; init; }
|
| 396 |
+
public int? Priority { get; init; }
|
| 397 |
+
public List<string>? Tags { get; init; }
|
| 398 |
+
public string? ChangeSummary { get; init; }
|
| 399 |
+
public RuleDefinition RuleDefinition { get; init; } = default!;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
public record SubmitApprovalRequest { public string? Comments { get; init; } }
|
| 403 |
+
public record ApproveRuleRequest { public string? Comments { get; init; } }
|
| 404 |
+
public record RejectRuleRequest { public string Comments { get; init; } = default!; }
|
| 405 |
+
public record CloneRuleRequest { public string? NewRuleCode { get; init; } public string? NewRuleName { get; init; } }
|
| 406 |
+
|
| 407 |
+
public record UpdateScopesRequest
|
| 408 |
+
{
|
| 409 |
+
public List<RuleScopeRequest> Scopes { get; init; } = new();
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
public record RuleScopeRequest
|
| 413 |
+
{
|
| 414 |
+
public string ScopeType { get; init; } = default!;
|
| 415 |
+
public string ScopeValue { get; init; } = default!;
|
| 416 |
+
public bool IsExcluded { get; init; }
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
public record RuleQuery
|
| 420 |
+
{
|
| 421 |
+
public Guid TenantId { get; init; }
|
| 422 |
+
public int Page { get; init; } = 1;
|
| 423 |
+
public int PageSize { get; init; } = 20;
|
| 424 |
+
public string? Category { get; init; }
|
| 425 |
+
public string? RuleType { get; init; }
|
| 426 |
+
public string? Status { get; init; }
|
| 427 |
+
public string? Search { get; init; }
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
public interface IRuleRepository
|
| 431 |
+
{
|
| 432 |
+
Task<PagedResponse<RuleSummaryDto>> GetPagedAsync(RuleQuery query, CancellationToken ct = default);
|
| 433 |
+
Task<Rule?> GetByIdAsync(Guid id, Guid tenantId, CancellationToken ct = default);
|
| 434 |
+
Task<Rule> CreateWithVersionAsync(Rule rule, RuleVersion version, CancellationToken ct = default);
|
| 435 |
+
Task UpdateWithNewVersionAsync(Rule rule, RuleVersion newVersion, CancellationToken ct = default);
|
| 436 |
+
Task PublishAsync(Guid id, Guid tenantId, Guid publishedBy, CancellationToken ct = default);
|
| 437 |
+
Task<Rule> CloneAsync(Guid id, Guid tenantId, Guid clonedBy, string? newCode, string? newName, CancellationToken ct = default);
|
| 438 |
+
Task ToggleActiveAsync(Guid id, Guid tenantId, CancellationToken ct = default);
|
| 439 |
+
Task UpdateScopesAsync(Guid id, Guid tenantId, List<RuleScopeRequest> scopes, CancellationToken ct = default);
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
public interface IRuleVersionRepository
|
| 443 |
+
{
|
| 444 |
+
Task<RuleVersion?> GetLatestAsync(Guid ruleId, CancellationToken ct = default);
|
| 445 |
+
Task<List<RuleVersion>> GetAllAsync(Guid ruleId, Guid tenantId, CancellationToken ct = default);
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
public interface IRuleApprovalService
|
| 449 |
+
{
|
| 450 |
+
Task SubmitForApprovalAsync(Guid ruleId, Guid versionId, Guid tenantId, Guid requestedBy, string? comments, CancellationToken ct = default);
|
| 451 |
+
Task ApproveAsync(Guid ruleId, Guid tenantId, Guid approvedBy, string? comments, CancellationToken ct = default);
|
| 452 |
+
Task RejectAsync(Guid ruleId, Guid tenantId, Guid rejectedBy, string comments, CancellationToken ct = default);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
public interface IAuditService
|
| 456 |
+
{
|
| 457 |
+
Task LogAsync(AuditLog log, CancellationToken ct = default);
|
| 458 |
+
}
|
RuleExecutionService.cs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Diagnostics;
|
| 2 |
+
using Microsoft.Extensions.Logging;
|
| 3 |
+
using OptimAI.BRE.RuleEngine.Domain;
|
| 4 |
+
using OptimAI.BRE.Shared.Domain;
|
| 5 |
+
|
| 6 |
+
namespace OptimAI.BRE.RuleEngine.Application;
|
| 7 |
+
|
| 8 |
+
public sealed class RuleExecutionService : IRuleExecutionService
|
| 9 |
+
{
|
| 10 |
+
private readonly IRuleLoader _ruleLoader;
|
| 11 |
+
private readonly IConditionEvaluator _conditionEvaluator;
|
| 12 |
+
private readonly IActionExecutor _actionExecutor;
|
| 13 |
+
private readonly IRiskScoringEngine _riskScoringEngine;
|
| 14 |
+
private readonly ILogger<RuleExecutionService> _logger;
|
| 15 |
+
|
| 16 |
+
public RuleExecutionService(
|
| 17 |
+
IRuleLoader ruleLoader,
|
| 18 |
+
IConditionEvaluator conditionEvaluator,
|
| 19 |
+
IActionExecutor actionExecutor,
|
| 20 |
+
IRiskScoringEngine riskScoringEngine,
|
| 21 |
+
ILogger<RuleExecutionService> logger)
|
| 22 |
+
{
|
| 23 |
+
_ruleLoader = ruleLoader;
|
| 24 |
+
_conditionEvaluator = conditionEvaluator;
|
| 25 |
+
_actionExecutor = actionExecutor;
|
| 26 |
+
_riskScoringEngine = riskScoringEngine;
|
| 27 |
+
_logger = logger;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
public async Task<ExecutionResult> ExecuteAsync(ExecutionContext context, CancellationToken ct = default)
|
| 31 |
+
{
|
| 32 |
+
var sw = Stopwatch.StartNew();
|
| 33 |
+
var state = new ExecutionState();
|
| 34 |
+
var ruleResults = new List<RuleEvaluationResult>();
|
| 35 |
+
|
| 36 |
+
try
|
| 37 |
+
{
|
| 38 |
+
var rules = await _ruleLoader.LoadRulesAsync(new RuleLoadRequest
|
| 39 |
+
{
|
| 40 |
+
TenantId = context.TenantId,
|
| 41 |
+
ProductCode = context.ProductCode,
|
| 42 |
+
BranchCode = context.BranchCode,
|
| 43 |
+
StageCode = context.StageCode,
|
| 44 |
+
RuleSetId = context.RuleSetId,
|
| 45 |
+
PublishedOnly = true
|
| 46 |
+
}, ct);
|
| 47 |
+
|
| 48 |
+
_logger.LogInformation("Executing {Count} rules for tenant {TenantId}, correlation {CorrelationId}",
|
| 49 |
+
rules.Count, context.TenantId, context.CorrelationId);
|
| 50 |
+
|
| 51 |
+
var executionState = new RuleExecutionState
|
| 52 |
+
{
|
| 53 |
+
GlobalState = state,
|
| 54 |
+
Data = context.Data,
|
| 55 |
+
TenantId = context.TenantId
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
var orderedRules = rules.OrderBy(r => r.Priority).ToList();
|
| 59 |
+
int executionOrder = 0;
|
| 60 |
+
|
| 61 |
+
foreach (var rule in orderedRules)
|
| 62 |
+
{
|
| 63 |
+
if (ct.IsCancellationRequested) break;
|
| 64 |
+
if (state.ShouldStop) break;
|
| 65 |
+
|
| 66 |
+
var ruleResult = await EvaluateRuleAsync(rule, context, ct);
|
| 67 |
+
ruleResult.ExecutionOrder = ++executionOrder;
|
| 68 |
+
ruleResults.Add(ruleResult);
|
| 69 |
+
|
| 70 |
+
if (ruleResult.IsMatched && !ruleResult.HasError)
|
| 71 |
+
{
|
| 72 |
+
foreach (var action in rule.CurrentVersion!.RuleDefinition.Actions)
|
| 73 |
+
{
|
| 74 |
+
await _actionExecutor.ExecuteAsync(action, context, executionState);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if (context.Options.StopOnFirstReject && state.CurrentDecision == Decision.Reject)
|
| 78 |
+
{
|
| 79 |
+
state.ShouldStop = true;
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Calculate risk score
|
| 85 |
+
var riskResult = await _riskScoringEngine.CalculateAsync(context, ruleResults);
|
| 86 |
+
state.RiskScore = riskResult.Score;
|
| 87 |
+
|
| 88 |
+
// Determine final traffic light
|
| 89 |
+
state.TrafficLight = DetermineTrafficLight(state.CurrentDecision, riskResult.Category);
|
| 90 |
+
|
| 91 |
+
sw.Stop();
|
| 92 |
+
|
| 93 |
+
return BuildResult(context, state, ruleResults, riskResult, sw.ElapsedMilliseconds);
|
| 94 |
+
}
|
| 95 |
+
catch (Exception ex)
|
| 96 |
+
{
|
| 97 |
+
_logger.LogError(ex, "Rule execution failed for tenant {TenantId}", context.TenantId);
|
| 98 |
+
throw;
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
public async Task<RuleEvaluationResult> EvaluateRuleAsync(Rule rule, ExecutionContext context, CancellationToken ct = default)
|
| 103 |
+
{
|
| 104 |
+
var sw = Stopwatch.StartNew();
|
| 105 |
+
var result = new RuleEvaluationResult
|
| 106 |
+
{
|
| 107 |
+
RuleId = rule.Id,
|
| 108 |
+
RuleCode = rule.RuleCode,
|
| 109 |
+
RuleName = rule.RuleName,
|
| 110 |
+
VersionNumber = rule.CurrentVersion?.VersionNumber ?? 0
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
try
|
| 114 |
+
{
|
| 115 |
+
if (rule.CurrentVersion?.RuleDefinition == null)
|
| 116 |
+
{
|
| 117 |
+
result.HasError = true;
|
| 118 |
+
result.ErrorMessage = "Rule has no published version";
|
| 119 |
+
return result;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
var definition = rule.CurrentVersion.RuleDefinition;
|
| 123 |
+
|
| 124 |
+
// Evaluate conditions with detailed tracking
|
| 125 |
+
var (matched, conditionResults) = await EvaluateConditionGroupDetailedAsync(
|
| 126 |
+
definition.Conditions, context.Data);
|
| 127 |
+
|
| 128 |
+
result.IsMatched = matched;
|
| 129 |
+
result.ConditionResults = conditionResults;
|
| 130 |
+
}
|
| 131 |
+
catch (Exception ex)
|
| 132 |
+
{
|
| 133 |
+
result.HasError = true;
|
| 134 |
+
result.ErrorMessage = ex.Message;
|
| 135 |
+
_logger.LogWarning(ex, "Error evaluating rule {RuleCode}", rule.RuleCode);
|
| 136 |
+
}
|
| 137 |
+
finally
|
| 138 |
+
{
|
| 139 |
+
sw.Stop();
|
| 140 |
+
result.ExecutionMs = (int)sw.ElapsedMilliseconds;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
return result;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
public async Task<bool> EvaluateConditionGroupAsync(ConditionGroup group, DynamicDataContext data, CancellationToken ct = default)
|
| 147 |
+
{
|
| 148 |
+
var results = new List<bool>();
|
| 149 |
+
|
| 150 |
+
foreach (var node in group.Rules)
|
| 151 |
+
{
|
| 152 |
+
bool nodeResult;
|
| 153 |
+
if (node.IsGroup && node.Group != null)
|
| 154 |
+
nodeResult = await EvaluateConditionGroupAsync(node.Group, data, ct);
|
| 155 |
+
else
|
| 156 |
+
nodeResult = _conditionEvaluator.Evaluate(node, data);
|
| 157 |
+
|
| 158 |
+
results.Add(nodeResult);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
return group.Operator switch
|
| 162 |
+
{
|
| 163 |
+
LogicalOperator.And => results.All(r => r),
|
| 164 |
+
LogicalOperator.Or => results.Any(r => r),
|
| 165 |
+
LogicalOperator.Not => results.Count > 0 && !results[0],
|
| 166 |
+
_ => false
|
| 167 |
+
};
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
private async Task<(bool matched, List<ConditionEvaluationResult> details)> EvaluateConditionGroupDetailedAsync(
|
| 171 |
+
ConditionGroup group, DynamicDataContext data)
|
| 172 |
+
{
|
| 173 |
+
var details = new List<ConditionEvaluationResult>();
|
| 174 |
+
var results = new List<bool>();
|
| 175 |
+
|
| 176 |
+
foreach (var node in group.Rules)
|
| 177 |
+
{
|
| 178 |
+
bool nodeResult;
|
| 179 |
+
if (node.IsGroup && node.Group != null)
|
| 180 |
+
{
|
| 181 |
+
var (groupResult, groupDetails) = await EvaluateConditionGroupDetailedAsync(node.Group, data);
|
| 182 |
+
nodeResult = groupResult;
|
| 183 |
+
details.AddRange(groupDetails);
|
| 184 |
+
}
|
| 185 |
+
else
|
| 186 |
+
{
|
| 187 |
+
var actualValue = data.GetValue(node.Field!);
|
| 188 |
+
nodeResult = _conditionEvaluator.Evaluate(node, data);
|
| 189 |
+
details.Add(new ConditionEvaluationResult
|
| 190 |
+
{
|
| 191 |
+
ConditionId = node.Id,
|
| 192 |
+
Field = node.Field ?? "",
|
| 193 |
+
Operator = node.Operator?.ToString() ?? "",
|
| 194 |
+
ExpectedValue = node.Value,
|
| 195 |
+
ActualValue = actualValue,
|
| 196 |
+
Result = nodeResult
|
| 197 |
+
});
|
| 198 |
+
}
|
| 199 |
+
results.Add(nodeResult);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
bool finalResult = group.Operator switch
|
| 203 |
+
{
|
| 204 |
+
LogicalOperator.And => results.All(r => r),
|
| 205 |
+
LogicalOperator.Or => results.Any(r => r),
|
| 206 |
+
LogicalOperator.Not => results.Count > 0 && !results[0],
|
| 207 |
+
_ => false
|
| 208 |
+
};
|
| 209 |
+
|
| 210 |
+
return (finalResult, details);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
private static TrafficLight DetermineTrafficLight(Decision decision, RiskCategory riskCategory)
|
| 214 |
+
{
|
| 215 |
+
return decision switch
|
| 216 |
+
{
|
| 217 |
+
Decision.Approve when riskCategory is RiskCategory.Low or RiskCategory.Medium => TrafficLight.Green,
|
| 218 |
+
Decision.Reject => TrafficLight.Red,
|
| 219 |
+
Decision.Deviation => TrafficLight.Amber,
|
| 220 |
+
_ => riskCategory switch
|
| 221 |
+
{
|
| 222 |
+
RiskCategory.Critical => TrafficLight.Red,
|
| 223 |
+
RiskCategory.High => TrafficLight.Amber,
|
| 224 |
+
_ => TrafficLight.Green
|
| 225 |
+
}
|
| 226 |
+
};
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
private static ExecutionResult BuildResult(
|
| 230 |
+
ExecutionContext context,
|
| 231 |
+
ExecutionState state,
|
| 232 |
+
List<RuleEvaluationResult> ruleResults,
|
| 233 |
+
RiskScoreResult riskResult,
|
| 234 |
+
long elapsedMs)
|
| 235 |
+
{
|
| 236 |
+
return new ExecutionResult
|
| 237 |
+
{
|
| 238 |
+
TenantId = context.TenantId,
|
| 239 |
+
FinalDecision = state.CurrentDecision,
|
| 240 |
+
RiskScore = state.RiskScore,
|
| 241 |
+
RiskCategory = riskResult.Category,
|
| 242 |
+
TrafficLight = state.TrafficLight,
|
| 243 |
+
TotalRulesEvaluated = ruleResults.Count,
|
| 244 |
+
RulesPassed = ruleResults.Count(r => r.IsMatched),
|
| 245 |
+
RulesFailed = ruleResults.Count(r => !r.IsMatched),
|
| 246 |
+
RulesSkipped = ruleResults.Count(r => r.HasError),
|
| 247 |
+
DeviationsCount = state.Deviations.Count,
|
| 248 |
+
ExecutionMs = (int)elapsedMs,
|
| 249 |
+
RuleResults = ruleResults.Select(r => new RuleExecutionSummary
|
| 250 |
+
{
|
| 251 |
+
RuleId = r.RuleId,
|
| 252 |
+
RuleCode = r.RuleCode,
|
| 253 |
+
RuleName = r.RuleName,
|
| 254 |
+
VersionNumber = r.VersionNumber,
|
| 255 |
+
IsMatched = r.IsMatched,
|
| 256 |
+
ConditionsEvaluated = r.ConditionResults,
|
| 257 |
+
ActionsExecuted = r.ExecutedActions,
|
| 258 |
+
ExecutionMs = r.ExecutionMs,
|
| 259 |
+
ErrorMessage = r.ErrorMessage
|
| 260 |
+
}).ToList(),
|
| 261 |
+
FieldValues = state.OutputFields
|
| 262 |
+
};
|
| 263 |
+
}
|
| 264 |
+
}
|
RulePreview.tsx
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React from 'react'
|
| 4 |
+
import type { RuleDefinition, ConditionGroup, ConditionNode, RuleAction, FieldCatalogEntry } from '@/types'
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
definition: RuleDefinition
|
| 8 |
+
fieldCatalog: FieldCatalogEntry[]
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const OPERATOR_LABELS: Record<string, string> = {
|
| 12 |
+
EQUALS: '=',
|
| 13 |
+
NOT_EQUALS: '≠',
|
| 14 |
+
GREATER_THAN: '>',
|
| 15 |
+
GREATER_THAN_OR_EQUAL: '≥',
|
| 16 |
+
LESS_THAN: '<',
|
| 17 |
+
LESS_THAN_OR_EQUAL: '≤',
|
| 18 |
+
BETWEEN: 'between',
|
| 19 |
+
IN: 'in',
|
| 20 |
+
NOT_IN: 'not in',
|
| 21 |
+
CONTAINS: 'contains',
|
| 22 |
+
IS_NULL: 'is empty',
|
| 23 |
+
IS_NOT_NULL: 'is not empty',
|
| 24 |
+
IS_TRUE: 'is true',
|
| 25 |
+
IS_FALSE: 'is false',
|
| 26 |
+
REGEX: 'matches',
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const ACTION_COLORS: Record<string, string> = {
|
| 30 |
+
SET_DECISION: 'bg-red-100 text-red-800',
|
| 31 |
+
SET_RISK: 'bg-yellow-100 text-yellow-800',
|
| 32 |
+
SET_TRAFFIC_LIGHT: 'bg-green-100 text-green-800',
|
| 33 |
+
ADD_DEVIATION: 'bg-orange-100 text-orange-800',
|
| 34 |
+
SET_FIELD: 'bg-blue-100 text-blue-800',
|
| 35 |
+
ADD_TAG: 'bg-purple-100 text-purple-800',
|
| 36 |
+
SET_SCORE: 'bg-teal-100 text-teal-800',
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export function RulePreview({ definition, fieldCatalog }: Props) {
|
| 40 |
+
const getFieldName = (path: string) =>
|
| 41 |
+
fieldCatalog.find((f) => f.fieldPath === path)?.displayName ?? path
|
| 42 |
+
|
| 43 |
+
const renderConditionGroup = (group: ConditionGroup, depth = 0): React.ReactNode => {
|
| 44 |
+
const indent = depth * 24
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<div style={{ marginLeft: indent }} className="space-y-1">
|
| 48 |
+
{group.rules.map((node, idx) => (
|
| 49 |
+
<div key={node.id}>
|
| 50 |
+
{idx > 0 && (
|
| 51 |
+
<div className="flex items-center gap-2 my-1">
|
| 52 |
+
<div className="w-4 h-px bg-gray-300" />
|
| 53 |
+
<span className={`px-2 py-0.5 rounded text-xs font-bold ${
|
| 54 |
+
group.operator === 'AND' ? 'bg-blue-100 text-blue-700' :
|
| 55 |
+
group.operator === 'OR' ? 'bg-purple-100 text-purple-700' :
|
| 56 |
+
'bg-red-100 text-red-700'
|
| 57 |
+
}`}>
|
| 58 |
+
{group.operator}
|
| 59 |
+
</span>
|
| 60 |
+
</div>
|
| 61 |
+
)}
|
| 62 |
+
{renderNode(node, depth)}
|
| 63 |
+
</div>
|
| 64 |
+
))}
|
| 65 |
+
</div>
|
| 66 |
+
)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const renderNode = (node: ConditionNode, depth: number): React.ReactNode => {
|
| 70 |
+
if (node.isGroup && node.group) {
|
| 71 |
+
return (
|
| 72 |
+
<div className="border-l-2 border-gray-200 pl-3 py-1">
|
| 73 |
+
<span className="text-xs text-gray-400 mb-1 block">{node.group.operator} group:</span>
|
| 74 |
+
{renderConditionGroup(node.group, depth + 1)}
|
| 75 |
+
</div>
|
| 76 |
+
)
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<div className="flex items-center gap-2 font-mono text-sm bg-gray-50 rounded-lg px-3 py-2 border border-gray-100">
|
| 81 |
+
<span className="text-blue-700 font-semibold">{getFieldName(node.field ?? '')}</span>
|
| 82 |
+
<span className="text-gray-500">{OPERATOR_LABELS[node.operator ?? ''] ?? node.operator}</span>
|
| 83 |
+
{node.value !== undefined && node.value !== '' && (
|
| 84 |
+
<span className="text-emerald-700 font-semibold">
|
| 85 |
+
{node.operator === 'BETWEEN'
|
| 86 |
+
? `${node.value} and ${node.value2}`
|
| 87 |
+
: String(node.value)}
|
| 88 |
+
</span>
|
| 89 |
+
)}
|
| 90 |
+
</div>
|
| 91 |
+
)
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const renderAction = (action: RuleAction): string => {
|
| 95 |
+
const colorClass = ACTION_COLORS[action.type] ?? 'bg-gray-100 text-gray-800'
|
| 96 |
+
switch (action.type) {
|
| 97 |
+
case 'SET_DECISION': return `Decision → ${action.value}`
|
| 98 |
+
case 'SET_RISK': return `Risk Level → ${action.value}`
|
| 99 |
+
case 'SET_TRAFFIC_LIGHT': return `Traffic Light → ${action.value}`
|
| 100 |
+
case 'ADD_DEVIATION': return `Deviation: ${action.value} (${action.parameters?.severity ?? 'MEDIUM'})`
|
| 101 |
+
case 'SET_FIELD': return `Set ${action.field} = ${action.value}`
|
| 102 |
+
case 'ADD_TAG': return `Tag: ${action.value}`
|
| 103 |
+
case 'SET_SCORE': return `Risk Score ${action.value?.startsWith?.('+') || action.value?.startsWith?.('-') ? 'adjust' : '='} ${action.value}`
|
| 104 |
+
default: return `${action.type}: ${action.value}`
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const hasConditions = definition.conditions.rules.length > 0
|
| 109 |
+
const hasActions = definition.actions.length > 0
|
| 110 |
+
|
| 111 |
+
return (
|
| 112 |
+
<div className="max-w-3xl mx-auto">
|
| 113 |
+
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
| 114 |
+
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-6 py-4">
|
| 115 |
+
<h2 className="text-white font-semibold text-lg">Rule Preview</h2>
|
| 116 |
+
<p className="text-blue-200 text-sm">Human-readable rule representation</p>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<div className="p-6 font-mono">
|
| 120 |
+
{/* IF block */}
|
| 121 |
+
<div className="mb-6">
|
| 122 |
+
<div className="flex items-center gap-3 mb-3">
|
| 123 |
+
<span className="px-3 py-1 bg-blue-600 text-white rounded-lg text-sm font-bold">IF</span>
|
| 124 |
+
<span className="text-gray-400 text-sm">all/any of these conditions match:</span>
|
| 125 |
+
</div>
|
| 126 |
+
{hasConditions ? (
|
| 127 |
+
renderConditionGroup(definition.conditions)
|
| 128 |
+
) : (
|
| 129 |
+
<p className="text-gray-400 text-sm italic ml-4">No conditions defined</p>
|
| 130 |
+
)}
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
{/* Divider */}
|
| 134 |
+
<div className="border-t-2 border-dashed border-gray-200 my-4" />
|
| 135 |
+
|
| 136 |
+
{/* THEN block */}
|
| 137 |
+
<div>
|
| 138 |
+
<div className="flex items-center gap-3 mb-3">
|
| 139 |
+
<span className="px-3 py-1 bg-emerald-600 text-white rounded-lg text-sm font-bold">THEN</span>
|
| 140 |
+
<span className="text-gray-400 text-sm">execute these actions:</span>
|
| 141 |
+
</div>
|
| 142 |
+
{hasActions ? (
|
| 143 |
+
<div className="space-y-2 ml-4">
|
| 144 |
+
{definition.actions.map((action, idx) => (
|
| 145 |
+
<div key={action.id} className="flex items-center gap-3">
|
| 146 |
+
<span className="text-xs text-gray-400">{idx + 1}.</span>
|
| 147 |
+
<span className={`px-2 py-1 rounded-lg text-xs font-semibold ${ACTION_COLORS[action.type] ?? 'bg-gray-100'}`}>
|
| 148 |
+
{renderAction(action)}
|
| 149 |
+
</span>
|
| 150 |
+
</div>
|
| 151 |
+
))}
|
| 152 |
+
</div>
|
| 153 |
+
) : (
|
| 154 |
+
<p className="text-gray-400 text-sm italic ml-4">No actions defined</p>
|
| 155 |
+
)}
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
{/* Metadata */}
|
| 159 |
+
{definition.metadata && (
|
| 160 |
+
<>
|
| 161 |
+
<div className="border-t border-gray-100 mt-6 pt-4">
|
| 162 |
+
<div className="flex gap-4 text-xs text-gray-400">
|
| 163 |
+
<span>Order: {definition.metadata.executionOrder ?? 0}</span>
|
| 164 |
+
<span>Error: {definition.metadata.errorHandling ?? 'SKIP'}</span>
|
| 165 |
+
{definition.metadata.stopOnMatch && (
|
| 166 |
+
<span className="text-amber-600 font-medium">⚡ Stop on Match</span>
|
| 167 |
+
)}
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</>
|
| 171 |
+
)}
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
)
|
| 176 |
+
}
|
SandboxPage.tsx
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react'
|
| 4 |
+
import { useMutation, useQuery } from '@tanstack/react-query'
|
| 5 |
+
import Editor from '@monaco-editor/react'
|
| 6 |
+
import type { BREDecisionResponse, PagedResponse } from '@/types'
|
| 7 |
+
import type { Rule } from '@/types'
|
| 8 |
+
import { apiClient } from '@/lib/api-client'
|
| 9 |
+
import { AiAnalystPanel } from '../ai-analyst/AiAnalystPanel'
|
| 10 |
+
|
| 11 |
+
const SAMPLE_PAYLOAD = JSON.stringify({
|
| 12 |
+
applicant: { age: 34, gender: "MALE", pan_number: "ABCDE1234F" },
|
| 13 |
+
employment: { type: "SALARIED", employer_name: "Infosys Ltd", monthly_income: 85000, vintage_months: 48 },
|
| 14 |
+
bureau: {
|
| 15 |
+
cibil_score: 712, experian_score: 708,
|
| 16 |
+
max_dpd_24m: 0, max_dpd_12m: 0,
|
| 17 |
+
total_active_loans: 2,
|
| 18 |
+
total_emi_obligation: 22000,
|
| 19 |
+
written_off_amount: 0,
|
| 20 |
+
suit_filed: false,
|
| 21 |
+
wilful_defaulter: false
|
| 22 |
+
},
|
| 23 |
+
loan: { amount: 750000, tenure_months: 60, purpose: "VEHICLE_PURCHASE" },
|
| 24 |
+
vehicle: { type: "TWO_WHEELER", make: "Honda", model: "Activa 6G", year: 2023, age_years: 0, valuation: 85000 },
|
| 25 |
+
ratios: { foir: 45.8, ltv: 88.2 },
|
| 26 |
+
fi: { verified: true, negative: false, address_match: true, mobile_match: true },
|
| 27 |
+
fraud: { score: 12, blacklisted: false }
|
| 28 |
+
}, null, 2)
|
| 29 |
+
|
| 30 |
+
export function SandboxPage() {
|
| 31 |
+
const [payload, setPayload] = useState(SAMPLE_PAYLOAD)
|
| 32 |
+
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>('')
|
| 33 |
+
const [result, setResult] = useState<BREDecisionResponse | null>(null)
|
| 34 |
+
const [error, setError] = useState<string | null>(null)
|
| 35 |
+
const [activeTab, setActiveTab] = useState<'input' | 'result' | 'ai'>('input')
|
| 36 |
+
|
| 37 |
+
const { data: ruleSets } = useQuery({
|
| 38 |
+
queryKey: ['rule-sets'],
|
| 39 |
+
queryFn: () => apiClient.get<{ items: { id: string; setName: string; setCode: string }[] }>('/rule-sets'),
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
const simulate = useMutation({
|
| 43 |
+
mutationFn: async () => {
|
| 44 |
+
const parsed = JSON.parse(payload)
|
| 45 |
+
return apiClient.post<BREDecisionResponse>('/simulate-decision', {
|
| 46 |
+
data: parsed,
|
| 47 |
+
ruleSetId: selectedRuleSetId || undefined,
|
| 48 |
+
})
|
| 49 |
+
},
|
| 50 |
+
onSuccess: (data) => {
|
| 51 |
+
setResult(data)
|
| 52 |
+
setError(null)
|
| 53 |
+
setActiveTab('result')
|
| 54 |
+
},
|
| 55 |
+
onError: (err: any) => {
|
| 56 |
+
setError(err.response?.data?.detail ?? err.message)
|
| 57 |
+
},
|
| 58 |
+
})
|
| 59 |
+
|
| 60 |
+
const isValidJson = (() => {
|
| 61 |
+
try { JSON.parse(payload); return true } catch { return false }
|
| 62 |
+
})()
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<div className="h-full flex flex-col">
|
| 66 |
+
{/* Header */}
|
| 67 |
+
<div className="px-6 py-4 border-b border-gray-200 bg-white">
|
| 68 |
+
<div className="flex items-center justify-between">
|
| 69 |
+
<div>
|
| 70 |
+
<h1 className="text-xl font-bold text-gray-900">Rule Testing Sandbox</h1>
|
| 71 |
+
<p className="text-sm text-gray-500 mt-0.5">Test rules before publishing to production</p>
|
| 72 |
+
</div>
|
| 73 |
+
<div className="flex items-center gap-3">
|
| 74 |
+
<select
|
| 75 |
+
value={selectedRuleSetId}
|
| 76 |
+
onChange={(e) => setSelectedRuleSetId(e.target.value)}
|
| 77 |
+
className="text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 78 |
+
>
|
| 79 |
+
<option value="">All Published Rules</option>
|
| 80 |
+
{ruleSets?.items.map((rs) => (
|
| 81 |
+
<option key={rs.id} value={rs.id}>{rs.setName}</option>
|
| 82 |
+
))}
|
| 83 |
+
</select>
|
| 84 |
+
<button
|
| 85 |
+
onClick={() => simulate.mutate()}
|
| 86 |
+
disabled={!isValidJson || simulate.isPending}
|
| 87 |
+
className={`px-6 py-2 rounded-lg text-sm font-semibold text-white transition-colors flex items-center gap-2 ${
|
| 88 |
+
isValidJson && !simulate.isPending
|
| 89 |
+
? 'bg-blue-600 hover:bg-blue-700'
|
| 90 |
+
: 'bg-gray-300 cursor-not-allowed'
|
| 91 |
+
}`}
|
| 92 |
+
>
|
| 93 |
+
{simulate.isPending ? (
|
| 94 |
+
<>
|
| 95 |
+
<span className="animate-spin">⏳</span> Running...
|
| 96 |
+
</>
|
| 97 |
+
) : (
|
| 98 |
+
<>▶ Run Simulation</>
|
| 99 |
+
)}
|
| 100 |
+
</button>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
{/* Tabs */}
|
| 106 |
+
<div className="flex border-b border-gray-200 bg-white px-6">
|
| 107 |
+
{[
|
| 108 |
+
{ id: 'input', label: '📥 Input JSON' },
|
| 109 |
+
{ id: 'result', label: `📤 Decision ${result ? `(${result.decision})` : ''}` },
|
| 110 |
+
{ id: 'ai', label: '🤖 AI Analysis' },
|
| 111 |
+
].map(({ id, label }) => (
|
| 112 |
+
<button
|
| 113 |
+
key={id}
|
| 114 |
+
onClick={() => setActiveTab(id as any)}
|
| 115 |
+
disabled={id !== 'input' && !result}
|
| 116 |
+
className={`py-3 px-4 text-sm font-medium border-b-2 transition-colors ${
|
| 117 |
+
activeTab === id
|
| 118 |
+
? 'border-blue-600 text-blue-600'
|
| 119 |
+
: 'border-transparent text-gray-500 hover:text-gray-700 disabled:opacity-40'
|
| 120 |
+
}`}
|
| 121 |
+
>
|
| 122 |
+
{label}
|
| 123 |
+
</button>
|
| 124 |
+
))}
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
{/* Content */}
|
| 128 |
+
<div className="flex-1 overflow-auto">
|
| 129 |
+
{activeTab === 'input' && (
|
| 130 |
+
<div className="h-full flex flex-col">
|
| 131 |
+
<div className="flex-1">
|
| 132 |
+
<Editor
|
| 133 |
+
height="100%"
|
| 134 |
+
defaultLanguage="json"
|
| 135 |
+
value={payload}
|
| 136 |
+
onChange={(v) => setPayload(v ?? '')}
|
| 137 |
+
theme="vs-light"
|
| 138 |
+
options={{
|
| 139 |
+
minimap: { enabled: false },
|
| 140 |
+
fontSize: 13,
|
| 141 |
+
lineNumbers: 'on',
|
| 142 |
+
scrollBeyondLastLine: false,
|
| 143 |
+
folding: true,
|
| 144 |
+
formatOnPaste: true,
|
| 145 |
+
}}
|
| 146 |
+
/>
|
| 147 |
+
</div>
|
| 148 |
+
{!isValidJson && (
|
| 149 |
+
<div className="px-6 py-2 bg-red-50 border-t border-red-200 text-sm text-red-700">
|
| 150 |
+
⚠ Invalid JSON — fix before running simulation
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
{error && (
|
| 154 |
+
<div className="px-6 py-3 bg-red-50 border-t border-red-200 text-sm text-red-700">
|
| 155 |
+
❌ {error}
|
| 156 |
+
</div>
|
| 157 |
+
)}
|
| 158 |
+
</div>
|
| 159 |
+
)}
|
| 160 |
+
|
| 161 |
+
{activeTab === 'result' && result && (
|
| 162 |
+
<div className="p-6 space-y-6">
|
| 163 |
+
{/* Decision Header */}
|
| 164 |
+
<DecisionHeader result={result} />
|
| 165 |
+
|
| 166 |
+
{/* Rule Results */}
|
| 167 |
+
<div className="bg-white rounded-xl border border-gray-200">
|
| 168 |
+
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
| 169 |
+
<h2 className="font-semibold text-gray-800">Rule Execution Results</h2>
|
| 170 |
+
<div className="flex gap-3 text-sm">
|
| 171 |
+
<span className="text-emerald-600">{result.rulesPassed} matched</span>
|
| 172 |
+
<span className="text-gray-400">•</span>
|
| 173 |
+
<span className="text-gray-500">{result.rulesFailed} not matched</span>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
<div className="divide-y divide-gray-50">
|
| 177 |
+
{result.ruleResults.map((r, idx) => (
|
| 178 |
+
<div
|
| 179 |
+
key={idx}
|
| 180 |
+
className={`flex items-center gap-4 px-5 py-3 ${r.isMatched ? 'bg-emerald-50/30' : ''}`}
|
| 181 |
+
>
|
| 182 |
+
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
| 183 |
+
r.isMatched ? 'bg-emerald-500 text-white' : 'bg-gray-200 text-gray-500'
|
| 184 |
+
}`}>
|
| 185 |
+
{r.isMatched ? '✓' : '✕'}
|
| 186 |
+
</div>
|
| 187 |
+
<div className="flex-1">
|
| 188 |
+
<div className="text-sm font-medium text-gray-800">{r.ruleName}</div>
|
| 189 |
+
<div className="text-xs text-gray-400 font-mono">{r.ruleCode}</div>
|
| 190 |
+
</div>
|
| 191 |
+
{r.isMatched && r.actionsExecuted.length > 0 && (
|
| 192 |
+
<div className="flex gap-1 flex-wrap">
|
| 193 |
+
{r.actionsExecuted.map((a, i) => (
|
| 194 |
+
<span key={i} className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">{a}</span>
|
| 195 |
+
))}
|
| 196 |
+
</div>
|
| 197 |
+
)}
|
| 198 |
+
<span className="text-xs text-gray-400 font-mono">{r.executionMs}ms</span>
|
| 199 |
+
</div>
|
| 200 |
+
))}
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
)}
|
| 205 |
+
|
| 206 |
+
{activeTab === 'ai' && result && (
|
| 207 |
+
<div className="p-6">
|
| 208 |
+
<AiAnalystPanel decision={result} />
|
| 209 |
+
</div>
|
| 210 |
+
)}
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
)
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function DecisionHeader({ result }: { result: BREDecisionResponse }) {
|
| 217 |
+
const decisionColors = {
|
| 218 |
+
APPROVE: 'from-emerald-600 to-emerald-700',
|
| 219 |
+
REJECT: 'from-red-600 to-red-700',
|
| 220 |
+
DEVIATION: 'from-amber-600 to-amber-700',
|
| 221 |
+
REFER: 'from-blue-600 to-blue-700',
|
| 222 |
+
PENDING: 'from-gray-600 to-gray-700',
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
return (
|
| 226 |
+
<div className={`bg-gradient-to-r ${decisionColors[result.decision] ?? 'from-gray-600 to-gray-700'} rounded-xl p-6 text-white`}>
|
| 227 |
+
<div className="flex items-center justify-between">
|
| 228 |
+
<div>
|
| 229 |
+
<div className="text-white/70 text-sm font-medium mb-1">FINAL DECISION</div>
|
| 230 |
+
<div className="text-4xl font-black">{result.decision}</div>
|
| 231 |
+
<div className="text-white/70 text-sm mt-1">in {result.executionMs}ms</div>
|
| 232 |
+
</div>
|
| 233 |
+
<div className="text-right">
|
| 234 |
+
<div className="text-5xl font-black">{result.riskScore.toFixed(0)}</div>
|
| 235 |
+
<div className="text-white/70 text-sm">Risk Score /100</div>
|
| 236 |
+
<div className="mt-1 inline-block px-3 py-1 bg-white/20 rounded-full text-sm font-semibold">
|
| 237 |
+
{result.riskCategory} RISK
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
<div className="mt-4 pt-4 border-t border-white/20 grid grid-cols-4 gap-4">
|
| 242 |
+
{[
|
| 243 |
+
{ label: 'Rules Evaluated', value: result.totalRulesEvaluated },
|
| 244 |
+
{ label: 'Rules Matched', value: result.rulesPassed },
|
| 245 |
+
{ label: 'Not Matched', value: result.rulesFailed },
|
| 246 |
+
{ label: 'Deviations', value: result.deviationsCount },
|
| 247 |
+
].map(({ label, value }) => (
|
| 248 |
+
<div key={label} className="text-center">
|
| 249 |
+
<div className="text-2xl font-bold">{value}</div>
|
| 250 |
+
<div className="text-white/60 text-xs">{label}</div>
|
| 251 |
+
</div>
|
| 252 |
+
))}
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
)
|
| 256 |
+
}
|
TenantMiddleware.cs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.EntityFrameworkCore;
|
| 2 |
+
using OptimAI.BRE.RuleEngine.Api;
|
| 3 |
+
using System.IdentityModel.Tokens.Jwt;
|
| 4 |
+
using System.Security.Claims;
|
| 5 |
+
|
| 6 |
+
namespace OptimAI.BRE.RuleEngine.Infrastructure;
|
| 7 |
+
|
| 8 |
+
/// <summary>
|
| 9 |
+
/// Resolves tenant from JWT claims (X-Tenant-ID header fallback).
|
| 10 |
+
/// Every request must carry a valid tenant context.
|
| 11 |
+
/// </summary>
|
| 12 |
+
public sealed class TenantResolutionMiddleware : IMiddleware
|
| 13 |
+
{
|
| 14 |
+
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
| 15 |
+
{
|
| 16 |
+
// Skip for health checks and swagger
|
| 17 |
+
if (ctx.Request.Path.StartsWithSegments("/health") ||
|
| 18 |
+
ctx.Request.Path.StartsWithSegments("/swagger"))
|
| 19 |
+
{
|
| 20 |
+
await next(ctx);
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
var tenantId = ResolveTenantId(ctx);
|
| 25 |
+
if (tenantId == null && !IsPublicEndpoint(ctx.Request.Path))
|
| 26 |
+
{
|
| 27 |
+
ctx.Response.StatusCode = 400;
|
| 28 |
+
await ctx.Response.WriteAsJsonAsync(new { error = "Tenant context missing" });
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
if (tenantId != null)
|
| 33 |
+
ctx.Items["TenantId"] = tenantId;
|
| 34 |
+
|
| 35 |
+
await next(ctx);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
private static Guid? ResolveTenantId(HttpContext ctx)
|
| 39 |
+
{
|
| 40 |
+
// 1. From JWT claim
|
| 41 |
+
var tenantClaim = ctx.User.FindFirst("tenant_id")?.Value;
|
| 42 |
+
if (tenantClaim != null && Guid.TryParse(tenantClaim, out var tid)) return tid;
|
| 43 |
+
|
| 44 |
+
// 2. From header (API key auth)
|
| 45 |
+
var header = ctx.Request.Headers["X-Tenant-ID"].FirstOrDefault();
|
| 46 |
+
if (header != null && Guid.TryParse(header, out var hid)) return hid;
|
| 47 |
+
|
| 48 |
+
return null;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
private static bool IsPublicEndpoint(PathString path) =>
|
| 52 |
+
path.StartsWithSegments("/api/v1/auth");
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/// <summary>
|
| 56 |
+
/// API Key authentication middleware. Validates X-API-Key header.
|
| 57 |
+
/// Populates tenant context and user claims from API key.
|
| 58 |
+
/// </summary>
|
| 59 |
+
public sealed class ApiKeyMiddleware : IMiddleware
|
| 60 |
+
{
|
| 61 |
+
private readonly BREDbContext _db;
|
| 62 |
+
|
| 63 |
+
public ApiKeyMiddleware(BREDbContext db) => _db = db;
|
| 64 |
+
|
| 65 |
+
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
| 66 |
+
{
|
| 67 |
+
var apiKey = ctx.Request.Headers["X-API-Key"].FirstOrDefault();
|
| 68 |
+
|
| 69 |
+
if (apiKey != null && !ctx.User.Identity?.IsAuthenticated == true)
|
| 70 |
+
{
|
| 71 |
+
var prefix = apiKey.Length >= 12 ? apiKey[..12] : apiKey;
|
| 72 |
+
var keyHash = HashApiKey(apiKey);
|
| 73 |
+
|
| 74 |
+
var key = await _db.ApiKeys
|
| 75 |
+
.FirstOrDefaultAsync(k =>
|
| 76 |
+
k.ApiKeyPrefix == prefix &&
|
| 77 |
+
k.ApiKeyHash == keyHash &&
|
| 78 |
+
k.IsActive &&
|
| 79 |
+
(k.ExpiresAt == null || k.ExpiresAt > DateTime.UtcNow));
|
| 80 |
+
|
| 81 |
+
if (key != null)
|
| 82 |
+
{
|
| 83 |
+
await _db.ApiKeys
|
| 84 |
+
.Where(k => k.Id == key.Id)
|
| 85 |
+
.ExecuteUpdateAsync(s => s.SetProperty(k => k.LastUsedAt, DateTime.UtcNow));
|
| 86 |
+
|
| 87 |
+
var claims = new List<Claim>
|
| 88 |
+
{
|
| 89 |
+
new(ClaimTypes.NameIdentifier, key.Id.ToString()),
|
| 90 |
+
new("tenant_id", key.TenantId.ToString()),
|
| 91 |
+
new("auth_type", "api_key"),
|
| 92 |
+
new("api_key_id", key.Id.ToString()),
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
foreach (var scope in key.Scopes)
|
| 96 |
+
claims.Add(new Claim("permission", scope));
|
| 97 |
+
|
| 98 |
+
var identity = new ClaimsIdentity(claims, "ApiKey");
|
| 99 |
+
ctx.User = new ClaimsPrincipal(identity);
|
| 100 |
+
ctx.Items["TenantId"] = key.TenantId;
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
await next(ctx);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
private static string HashApiKey(string key)
|
| 108 |
+
{
|
| 109 |
+
using var sha = System.Security.Cryptography.SHA256.Create();
|
| 110 |
+
var bytes = System.Text.Encoding.UTF8.GetBytes(key);
|
| 111 |
+
return Convert.ToHexString(sha.ComputeHash(bytes)).ToLower();
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/// <summary>
|
| 116 |
+
/// HttpContext-scoped tenant and user context accessor.
|
| 117 |
+
/// </summary>
|
| 118 |
+
public sealed class TenantContextAccessor : ITenantContextAccessor
|
| 119 |
+
{
|
| 120 |
+
private readonly IHttpContextAccessor _httpContextAccessor;
|
| 121 |
+
|
| 122 |
+
public TenantContextAccessor(IHttpContextAccessor httpContextAccessor)
|
| 123 |
+
=> _httpContextAccessor = httpContextAccessor;
|
| 124 |
+
|
| 125 |
+
public Guid TenantId
|
| 126 |
+
{
|
| 127 |
+
get
|
| 128 |
+
{
|
| 129 |
+
var ctx = _httpContextAccessor.HttpContext;
|
| 130 |
+
if (ctx?.Items["TenantId"] is Guid id) return id;
|
| 131 |
+
|
| 132 |
+
var claim = ctx?.User.FindFirst("tenant_id")?.Value;
|
| 133 |
+
return claim != null && Guid.TryParse(claim, out var tid) ? tid : Guid.Empty;
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
public Guid UserId
|
| 138 |
+
{
|
| 139 |
+
get
|
| 140 |
+
{
|
| 141 |
+
var claim = _httpContextAccessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
| 142 |
+
return claim != null && Guid.TryParse(claim, out var id) ? id : Guid.Empty;
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
public string? UserEmail =>
|
| 147 |
+
_httpContextAccessor.HttpContext?.User.FindFirst(ClaimTypes.Email)?.Value;
|
| 148 |
+
}
|
api-client.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios, { type AxiosInstance, type AxiosError } from 'axios'
|
| 2 |
+
|
| 3 |
+
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000/api/v1'
|
| 4 |
+
|
| 5 |
+
function createApiClient(): AxiosInstance {
|
| 6 |
+
const client = axios.create({
|
| 7 |
+
baseURL: BASE_URL,
|
| 8 |
+
timeout: 30_000,
|
| 9 |
+
headers: { 'Content-Type': 'application/json' },
|
| 10 |
+
})
|
| 11 |
+
|
| 12 |
+
// Attach JWT token
|
| 13 |
+
client.interceptors.request.use((config) => {
|
| 14 |
+
const token = typeof window !== 'undefined' ? localStorage.getItem('bre_access_token') : null
|
| 15 |
+
if (token) config.headers.Authorization = `Bearer ${token}`
|
| 16 |
+
return config
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
// Handle 401 → refresh or redirect
|
| 20 |
+
client.interceptors.response.use(
|
| 21 |
+
(res) => res,
|
| 22 |
+
async (error: AxiosError) => {
|
| 23 |
+
if (error.response?.status === 401) {
|
| 24 |
+
const refreshToken = localStorage.getItem('bre_refresh_token')
|
| 25 |
+
if (refreshToken) {
|
| 26 |
+
try {
|
| 27 |
+
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, { refreshToken })
|
| 28 |
+
localStorage.setItem('bre_access_token', data.accessToken)
|
| 29 |
+
error.config!.headers!.Authorization = `Bearer ${data.accessToken}`
|
| 30 |
+
return client.request(error.config!)
|
| 31 |
+
} catch {
|
| 32 |
+
localStorage.clear()
|
| 33 |
+
window.location.href = '/login'
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
return Promise.reject(error)
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
return client
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const instance = createApiClient()
|
| 45 |
+
|
| 46 |
+
export const apiClient = {
|
| 47 |
+
get: <T>(url: string, params?: object): Promise<T> =>
|
| 48 |
+
instance.get(url, { params }).then((r) => r.data),
|
| 49 |
+
|
| 50 |
+
post: <T>(url: string, data?: object): Promise<T> =>
|
| 51 |
+
instance.post(url, data).then((r) => r.data),
|
| 52 |
+
|
| 53 |
+
put: <T>(url: string, data?: object): Promise<T> =>
|
| 54 |
+
instance.put(url, data).then((r) => r.data),
|
| 55 |
+
|
| 56 |
+
patch: <T>(url: string, data?: object): Promise<T> =>
|
| 57 |
+
instance.patch(url, data).then((r) => r.data),
|
| 58 |
+
|
| 59 |
+
delete: <T>(url: string): Promise<T> =>
|
| 60 |
+
instance.delete(url).then((r) => r.data),
|
| 61 |
+
}
|
appsettings.Development.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ConnectionStrings": {
|
| 3 |
+
"PostgreSQL": "Host=localhost;Port=5432;Database=optimai_bre;Username=optimai;Password=dev_password;Include Error Detail=true",
|
| 4 |
+
"Redis": "localhost:6379"
|
| 5 |
+
},
|
| 6 |
+
"Jwt": {
|
| 7 |
+
"SecretKey": "DEV_SECRET_KEY_CHANGE_IN_PRODUCTION_MIN_32_CHARS",
|
| 8 |
+
"Issuer": "OptimAI.BRE",
|
| 9 |
+
"Audience": "OptimAI.BRE.Clients",
|
| 10 |
+
"AccessTokenExpiryMinutes": 480,
|
| 11 |
+
"RefreshTokenExpiryDays": 30
|
| 12 |
+
},
|
| 13 |
+
"AiOptions": {
|
| 14 |
+
"Endpoint": "",
|
| 15 |
+
"ApiKey": "",
|
| 16 |
+
"ModelName": "gpt-4o",
|
| 17 |
+
"UseAzureOpenAI": false
|
| 18 |
+
},
|
| 19 |
+
"AllowedOrigins": [
|
| 20 |
+
"http://localhost:3000",
|
| 21 |
+
"https://localhost:3000",
|
| 22 |
+
"http://localhost:3001"
|
| 23 |
+
],
|
| 24 |
+
"Serilog": {
|
| 25 |
+
"MinimumLevel": {
|
| 26 |
+
"Default": "Debug",
|
| 27 |
+
"Override": {
|
| 28 |
+
"Microsoft": "Information",
|
| 29 |
+
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
|
| 30 |
+
"System": "Warning"
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
},
|
| 34 |
+
"RuleEngine": {
|
| 35 |
+
"MaxConcurrentExecutions": 10,
|
| 36 |
+
"DefaultTimeoutMs": 30000,
|
| 37 |
+
"EnableAiByDefault": false,
|
| 38 |
+
"CacheRulesTtlMinutes": 1
|
| 39 |
+
}
|
| 40 |
+
}
|
appsettings.Production.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ConnectionStrings": {
|
| 3 |
+
"PostgreSQL": "Host=bre-postgres;Port=5432;Database=optimai_bre;Username=optimai;Password=PLACEHOLDER_OVERRIDDEN_BY_ENV;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=100;",
|
| 4 |
+
"Redis": "bre-redis:6379,password=PLACEHOLDER_OVERRIDDEN_BY_ENV,abortConnect=false"
|
| 5 |
+
},
|
| 6 |
+
"Jwt": {
|
| 7 |
+
"SecretKey": "PLACEHOLDER_OVERRIDDEN_BY_ENV",
|
| 8 |
+
"Issuer": "OptimAI.BRE",
|
| 9 |
+
"Audience": "OptimAI.BRE.Clients",
|
| 10 |
+
"AccessTokenExpiryMinutes": 480,
|
| 11 |
+
"RefreshTokenExpiryDays": 30
|
| 12 |
+
},
|
| 13 |
+
"AiOptions": {
|
| 14 |
+
"Endpoint": "",
|
| 15 |
+
"ApiKey": "",
|
| 16 |
+
"ModelName": "gpt-4o",
|
| 17 |
+
"UseAzureOpenAI": false
|
| 18 |
+
},
|
| 19 |
+
"AllowedOrigins": [
|
| 20 |
+
"http://localhost",
|
| 21 |
+
"https://localhost"
|
| 22 |
+
],
|
| 23 |
+
"Serilog": {
|
| 24 |
+
"MinimumLevel": {
|
| 25 |
+
"Default": "Information",
|
| 26 |
+
"Override": {
|
| 27 |
+
"Microsoft": "Warning",
|
| 28 |
+
"Microsoft.EntityFrameworkCore": "Warning",
|
| 29 |
+
"System": "Warning"
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
},
|
| 33 |
+
"RuleEngine": {
|
| 34 |
+
"MaxConcurrentExecutions": 100,
|
| 35 |
+
"DefaultTimeoutMs": 5000,
|
| 36 |
+
"EnableAiByDefault": false,
|
| 37 |
+
"CacheRulesTtlMinutes": 5
|
| 38 |
+
}
|
| 39 |
+
}
|
appsettings.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ConnectionStrings": {
|
| 3 |
+
"PostgreSQL": "Host=localhost;Database=optimai_bre;Username=optimai;Password=dev_password",
|
| 4 |
+
"Redis": "localhost:6379"
|
| 5 |
+
},
|
| 6 |
+
"Jwt": {
|
| 7 |
+
"SecretKey": "DEV_SECRET_KEY_CHANGE_IN_PRODUCTION_MIN_32_CHARS",
|
| 8 |
+
"Issuer": "OptimAI.BRE",
|
| 9 |
+
"Audience": "OptimAI.BRE.Clients",
|
| 10 |
+
"AccessTokenExpiryMinutes": 60,
|
| 11 |
+
"RefreshTokenExpiryDays": 30
|
| 12 |
+
},
|
| 13 |
+
"AiOptions": {
|
| 14 |
+
"Endpoint": "",
|
| 15 |
+
"ApiKey": "",
|
| 16 |
+
"ModelName": "gpt-4o",
|
| 17 |
+
"UseAzureOpenAI": true
|
| 18 |
+
},
|
| 19 |
+
"AllowedOrigins": [
|
| 20 |
+
"http://localhost:3000",
|
| 21 |
+
"https://localhost:3000"
|
| 22 |
+
],
|
| 23 |
+
"Serilog": {
|
| 24 |
+
"MinimumLevel": {
|
| 25 |
+
"Default": "Information",
|
| 26 |
+
"Override": {
|
| 27 |
+
"Microsoft": "Warning",
|
| 28 |
+
"Microsoft.EntityFrameworkCore": "Warning",
|
| 29 |
+
"System": "Warning"
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
},
|
| 33 |
+
"RuleEngine": {
|
| 34 |
+
"MaxConcurrentExecutions": 100,
|
| 35 |
+
"DefaultTimeoutMs": 5000,
|
| 36 |
+
"EnableAiByDefault": true,
|
| 37 |
+
"CacheRulesTtlMinutes": 5
|
| 38 |
+
}
|
| 39 |
+
}
|
deployment.yaml
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
# ============================================================
|
| 3 |
+
# OPTIM AI BRE Engine - Kubernetes Manifests
|
| 4 |
+
# Production-grade deployment with HPA, PodDisruptionBudget
|
| 5 |
+
# ============================================================
|
| 6 |
+
|
| 7 |
+
apiVersion: v1
|
| 8 |
+
kind: Namespace
|
| 9 |
+
metadata:
|
| 10 |
+
name: optimai-bre
|
| 11 |
+
labels:
|
| 12 |
+
app.kubernetes.io/name: optimai-bre
|
| 13 |
+
environment: production
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
# ============================================================
|
| 17 |
+
# CONFIGMAP
|
| 18 |
+
# ============================================================
|
| 19 |
+
apiVersion: v1
|
| 20 |
+
kind: ConfigMap
|
| 21 |
+
metadata:
|
| 22 |
+
name: bre-config
|
| 23 |
+
namespace: optimai-bre
|
| 24 |
+
data:
|
| 25 |
+
ASPNETCORE_ENVIRONMENT: "Production"
|
| 26 |
+
ASPNETCORE_URLS: "http://+:8080"
|
| 27 |
+
AiOptions__UseAzureOpenAI: "true"
|
| 28 |
+
AiOptions__ModelName: "gpt-4o"
|
| 29 |
+
Serilog__MinimumLevel__Default: "Information"
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
# ============================================================
|
| 33 |
+
# SECRETS (use Sealed Secrets or Azure Key Vault in prod)
|
| 34 |
+
# ============================================================
|
| 35 |
+
apiVersion: v1
|
| 36 |
+
kind: Secret
|
| 37 |
+
metadata:
|
| 38 |
+
name: bre-secrets
|
| 39 |
+
namespace: optimai-bre
|
| 40 |
+
type: Opaque
|
| 41 |
+
stringData:
|
| 42 |
+
postgres-connection: "Host=postgres-service;Database=optimai_bre;Username=optimai;Password=CHANGE_ME"
|
| 43 |
+
redis-connection: "redis-service:6379,password=CHANGE_ME"
|
| 44 |
+
jwt-secret-key: "CHANGE_ME_TO_STRONG_RANDOM_KEY_MIN_32_CHARS"
|
| 45 |
+
azure-openai-endpoint: "https://your-resource.openai.azure.com/"
|
| 46 |
+
azure-openai-key: "CHANGE_ME"
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
# ============================================================
|
| 50 |
+
# BRE GATEWAY DEPLOYMENT
|
| 51 |
+
# ============================================================
|
| 52 |
+
apiVersion: apps/v1
|
| 53 |
+
kind: Deployment
|
| 54 |
+
metadata:
|
| 55 |
+
name: bre-gateway
|
| 56 |
+
namespace: optimai-bre
|
| 57 |
+
labels:
|
| 58 |
+
app: bre-gateway
|
| 59 |
+
version: "1.0.0"
|
| 60 |
+
spec:
|
| 61 |
+
replicas: 3
|
| 62 |
+
selector:
|
| 63 |
+
matchLabels:
|
| 64 |
+
app: bre-gateway
|
| 65 |
+
strategy:
|
| 66 |
+
type: RollingUpdate
|
| 67 |
+
rollingUpdate:
|
| 68 |
+
maxSurge: 1
|
| 69 |
+
maxUnavailable: 0
|
| 70 |
+
template:
|
| 71 |
+
metadata:
|
| 72 |
+
labels:
|
| 73 |
+
app: bre-gateway
|
| 74 |
+
version: "1.0.0"
|
| 75 |
+
annotations:
|
| 76 |
+
prometheus.io/scrape: "true"
|
| 77 |
+
prometheus.io/port: "8080"
|
| 78 |
+
prometheus.io/path: "/metrics"
|
| 79 |
+
spec:
|
| 80 |
+
terminationGracePeriodSeconds: 30
|
| 81 |
+
affinity:
|
| 82 |
+
podAntiAffinity:
|
| 83 |
+
requiredDuringSchedulingIgnoredDuringExecution:
|
| 84 |
+
- labelSelector:
|
| 85 |
+
matchExpressions:
|
| 86 |
+
- key: app
|
| 87 |
+
operator: In
|
| 88 |
+
values:
|
| 89 |
+
- bre-gateway
|
| 90 |
+
topologyKey: kubernetes.io/hostname
|
| 91 |
+
containers:
|
| 92 |
+
- name: bre-gateway
|
| 93 |
+
image: optimai/bre-gateway:1.0.0
|
| 94 |
+
imagePullPolicy: Always
|
| 95 |
+
ports:
|
| 96 |
+
- containerPort: 8080
|
| 97 |
+
name: http
|
| 98 |
+
env:
|
| 99 |
+
- name: ConnectionStrings__PostgreSQL
|
| 100 |
+
valueFrom:
|
| 101 |
+
secretKeyRef:
|
| 102 |
+
name: bre-secrets
|
| 103 |
+
key: postgres-connection
|
| 104 |
+
- name: ConnectionStrings__Redis
|
| 105 |
+
valueFrom:
|
| 106 |
+
secretKeyRef:
|
| 107 |
+
name: bre-secrets
|
| 108 |
+
key: redis-connection
|
| 109 |
+
- name: Jwt__SecretKey
|
| 110 |
+
valueFrom:
|
| 111 |
+
secretKeyRef:
|
| 112 |
+
name: bre-secrets
|
| 113 |
+
key: jwt-secret-key
|
| 114 |
+
- name: AiOptions__Endpoint
|
| 115 |
+
valueFrom:
|
| 116 |
+
secretKeyRef:
|
| 117 |
+
name: bre-secrets
|
| 118 |
+
key: azure-openai-endpoint
|
| 119 |
+
- name: AiOptions__ApiKey
|
| 120 |
+
valueFrom:
|
| 121 |
+
secretKeyRef:
|
| 122 |
+
name: bre-secrets
|
| 123 |
+
key: azure-openai-key
|
| 124 |
+
envFrom:
|
| 125 |
+
- configMapRef:
|
| 126 |
+
name: bre-config
|
| 127 |
+
resources:
|
| 128 |
+
requests:
|
| 129 |
+
memory: "256Mi"
|
| 130 |
+
cpu: "250m"
|
| 131 |
+
limits:
|
| 132 |
+
memory: "512Mi"
|
| 133 |
+
cpu: "1000m"
|
| 134 |
+
readinessProbe:
|
| 135 |
+
httpGet:
|
| 136 |
+
path: /health/ready
|
| 137 |
+
port: 8080
|
| 138 |
+
initialDelaySeconds: 20
|
| 139 |
+
periodSeconds: 10
|
| 140 |
+
failureThreshold: 3
|
| 141 |
+
livenessProbe:
|
| 142 |
+
httpGet:
|
| 143 |
+
path: /health
|
| 144 |
+
port: 8080
|
| 145 |
+
initialDelaySeconds: 30
|
| 146 |
+
periodSeconds: 30
|
| 147 |
+
failureThreshold: 3
|
| 148 |
+
lifecycle:
|
| 149 |
+
preStop:
|
| 150 |
+
exec:
|
| 151 |
+
command: ["/bin/sh", "-c", "sleep 5"]
|
| 152 |
+
volumeMounts:
|
| 153 |
+
- name: logs
|
| 154 |
+
mountPath: /app/logs
|
| 155 |
+
volumes:
|
| 156 |
+
- name: logs
|
| 157 |
+
emptyDir: {}
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
# ============================================================
|
| 161 |
+
# BRE GATEWAY SERVICE
|
| 162 |
+
# ============================================================
|
| 163 |
+
apiVersion: v1
|
| 164 |
+
kind: Service
|
| 165 |
+
metadata:
|
| 166 |
+
name: bre-gateway-service
|
| 167 |
+
namespace: optimai-bre
|
| 168 |
+
labels:
|
| 169 |
+
app: bre-gateway
|
| 170 |
+
spec:
|
| 171 |
+
type: ClusterIP
|
| 172 |
+
selector:
|
| 173 |
+
app: bre-gateway
|
| 174 |
+
ports:
|
| 175 |
+
- port: 80
|
| 176 |
+
targetPort: 8080
|
| 177 |
+
name: http
|
| 178 |
+
|
| 179 |
+
---
|
| 180 |
+
# ============================================================
|
| 181 |
+
# HORIZONTAL POD AUTOSCALER
|
| 182 |
+
# ============================================================
|
| 183 |
+
apiVersion: autoscaling/v2
|
| 184 |
+
kind: HorizontalPodAutoscaler
|
| 185 |
+
metadata:
|
| 186 |
+
name: bre-gateway-hpa
|
| 187 |
+
namespace: optimai-bre
|
| 188 |
+
spec:
|
| 189 |
+
scaleTargetRef:
|
| 190 |
+
apiVersion: apps/v1
|
| 191 |
+
kind: Deployment
|
| 192 |
+
name: bre-gateway
|
| 193 |
+
minReplicas: 3
|
| 194 |
+
maxReplicas: 20
|
| 195 |
+
metrics:
|
| 196 |
+
- type: Resource
|
| 197 |
+
resource:
|
| 198 |
+
name: cpu
|
| 199 |
+
target:
|
| 200 |
+
type: Utilization
|
| 201 |
+
averageUtilization: 70
|
| 202 |
+
- type: Resource
|
| 203 |
+
resource:
|
| 204 |
+
name: memory
|
| 205 |
+
target:
|
| 206 |
+
type: Utilization
|
| 207 |
+
averageUtilization: 80
|
| 208 |
+
behavior:
|
| 209 |
+
scaleUp:
|
| 210 |
+
stabilizationWindowSeconds: 60
|
| 211 |
+
policies:
|
| 212 |
+
- type: Pods
|
| 213 |
+
value: 2
|
| 214 |
+
periodSeconds: 60
|
| 215 |
+
scaleDown:
|
| 216 |
+
stabilizationWindowSeconds: 300
|
| 217 |
+
policies:
|
| 218 |
+
- type: Pods
|
| 219 |
+
value: 1
|
| 220 |
+
periodSeconds: 120
|
| 221 |
+
|
| 222 |
+
---
|
| 223 |
+
# ============================================================
|
| 224 |
+
# POD DISRUPTION BUDGET
|
| 225 |
+
# ============================================================
|
| 226 |
+
apiVersion: policy/v1
|
| 227 |
+
kind: PodDisruptionBudget
|
| 228 |
+
metadata:
|
| 229 |
+
name: bre-gateway-pdb
|
| 230 |
+
namespace: optimai-bre
|
| 231 |
+
spec:
|
| 232 |
+
minAvailable: 2
|
| 233 |
+
selector:
|
| 234 |
+
matchLabels:
|
| 235 |
+
app: bre-gateway
|
| 236 |
+
|
| 237 |
+
---
|
| 238 |
+
# ============================================================
|
| 239 |
+
# FRONTEND DEPLOYMENT
|
| 240 |
+
# ============================================================
|
| 241 |
+
apiVersion: apps/v1
|
| 242 |
+
kind: Deployment
|
| 243 |
+
metadata:
|
| 244 |
+
name: bre-frontend
|
| 245 |
+
namespace: optimai-bre
|
| 246 |
+
spec:
|
| 247 |
+
replicas: 2
|
| 248 |
+
selector:
|
| 249 |
+
matchLabels:
|
| 250 |
+
app: bre-frontend
|
| 251 |
+
template:
|
| 252 |
+
metadata:
|
| 253 |
+
labels:
|
| 254 |
+
app: bre-frontend
|
| 255 |
+
spec:
|
| 256 |
+
containers:
|
| 257 |
+
- name: bre-frontend
|
| 258 |
+
image: optimai/bre-frontend:1.0.0
|
| 259 |
+
ports:
|
| 260 |
+
- containerPort: 3000
|
| 261 |
+
env:
|
| 262 |
+
- name: NEXT_PUBLIC_API_URL
|
| 263 |
+
value: "https://api.optimaibre.com/api/v1"
|
| 264 |
+
resources:
|
| 265 |
+
requests:
|
| 266 |
+
memory: "128Mi"
|
| 267 |
+
cpu: "100m"
|
| 268 |
+
limits:
|
| 269 |
+
memory: "256Mi"
|
| 270 |
+
cpu: "500m"
|
| 271 |
+
readinessProbe:
|
| 272 |
+
httpGet:
|
| 273 |
+
path: /api/health
|
| 274 |
+
port: 3000
|
| 275 |
+
initialDelaySeconds: 15
|
| 276 |
+
periodSeconds: 10
|
| 277 |
+
|
| 278 |
+
---
|
| 279 |
+
apiVersion: v1
|
| 280 |
+
kind: Service
|
| 281 |
+
metadata:
|
| 282 |
+
name: bre-frontend-service
|
| 283 |
+
namespace: optimai-bre
|
| 284 |
+
spec:
|
| 285 |
+
type: ClusterIP
|
| 286 |
+
selector:
|
| 287 |
+
app: bre-frontend
|
| 288 |
+
ports:
|
| 289 |
+
- port: 80
|
| 290 |
+
targetPort: 3000
|
| 291 |
+
|
| 292 |
+
---
|
| 293 |
+
# ============================================================
|
| 294 |
+
# INGRESS (NGINX / Azure Application Gateway)
|
| 295 |
+
# ============================================================
|
| 296 |
+
apiVersion: networking.k8s.io/v1
|
| 297 |
+
kind: Ingress
|
| 298 |
+
metadata:
|
| 299 |
+
name: bre-ingress
|
| 300 |
+
namespace: optimai-bre
|
| 301 |
+
annotations:
|
| 302 |
+
kubernetes.io/ingress.class: nginx
|
| 303 |
+
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
| 304 |
+
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
| 305 |
+
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
|
| 306 |
+
nginx.ingress.kubernetes.io/rate-limit: "1000"
|
| 307 |
+
nginx.ingress.kubernetes.io/rate-limit-window: "1m"
|
| 308 |
+
cert-manager.io/cluster-issuer: letsencrypt-prod
|
| 309 |
+
spec:
|
| 310 |
+
tls:
|
| 311 |
+
- hosts:
|
| 312 |
+
- api.optimaibre.com
|
| 313 |
+
- app.optimaibre.com
|
| 314 |
+
secretName: bre-tls-secret
|
| 315 |
+
rules:
|
| 316 |
+
- host: api.optimaibre.com
|
| 317 |
+
http:
|
| 318 |
+
paths:
|
| 319 |
+
- path: /
|
| 320 |
+
pathType: Prefix
|
| 321 |
+
backend:
|
| 322 |
+
service:
|
| 323 |
+
name: bre-gateway-service
|
| 324 |
+
port:
|
| 325 |
+
number: 80
|
| 326 |
+
- host: app.optimaibre.com
|
| 327 |
+
http:
|
| 328 |
+
paths:
|
| 329 |
+
- path: /
|
| 330 |
+
pathType: Prefix
|
| 331 |
+
backend:
|
| 332 |
+
service:
|
| 333 |
+
name: bre-frontend-service
|
| 334 |
+
port:
|
| 335 |
+
number: 80
|
| 336 |
+
|
| 337 |
+
---
|
| 338 |
+
# ============================================================
|
| 339 |
+
# POSTGRESQL StatefulSet
|
| 340 |
+
# ============================================================
|
| 341 |
+
apiVersion: apps/v1
|
| 342 |
+
kind: StatefulSet
|
| 343 |
+
metadata:
|
| 344 |
+
name: postgres
|
| 345 |
+
namespace: optimai-bre
|
| 346 |
+
spec:
|
| 347 |
+
serviceName: postgres-service
|
| 348 |
+
replicas: 1
|
| 349 |
+
selector:
|
| 350 |
+
matchLabels:
|
| 351 |
+
app: postgres
|
| 352 |
+
template:
|
| 353 |
+
metadata:
|
| 354 |
+
labels:
|
| 355 |
+
app: postgres
|
| 356 |
+
spec:
|
| 357 |
+
containers:
|
| 358 |
+
- name: postgres
|
| 359 |
+
image: postgres:15-alpine
|
| 360 |
+
env:
|
| 361 |
+
- name: POSTGRES_DB
|
| 362 |
+
value: optimai_bre
|
| 363 |
+
- name: POSTGRES_USER
|
| 364 |
+
value: optimai
|
| 365 |
+
- name: POSTGRES_PASSWORD
|
| 366 |
+
valueFrom:
|
| 367 |
+
secretKeyRef:
|
| 368 |
+
name: bre-secrets
|
| 369 |
+
key: postgres-password
|
| 370 |
+
- name: PGDATA
|
| 371 |
+
value: /var/lib/postgresql/data/pgdata
|
| 372 |
+
ports:
|
| 373 |
+
- containerPort: 5432
|
| 374 |
+
resources:
|
| 375 |
+
requests:
|
| 376 |
+
memory: "512Mi"
|
| 377 |
+
cpu: "500m"
|
| 378 |
+
limits:
|
| 379 |
+
memory: "2Gi"
|
| 380 |
+
cpu: "2000m"
|
| 381 |
+
volumeMounts:
|
| 382 |
+
- name: postgres-data
|
| 383 |
+
mountPath: /var/lib/postgresql/data
|
| 384 |
+
readinessProbe:
|
| 385 |
+
exec:
|
| 386 |
+
command: ["pg_isready", "-U", "optimai", "-d", "optimai_bre"]
|
| 387 |
+
initialDelaySeconds: 10
|
| 388 |
+
periodSeconds: 5
|
| 389 |
+
volumeClaimTemplates:
|
| 390 |
+
- metadata:
|
| 391 |
+
name: postgres-data
|
| 392 |
+
spec:
|
| 393 |
+
accessModes: ["ReadWriteOnce"]
|
| 394 |
+
storageClassName: "managed-premium"
|
| 395 |
+
resources:
|
| 396 |
+
requests:
|
| 397 |
+
storage: 100Gi
|
| 398 |
+
|
| 399 |
+
---
|
| 400 |
+
apiVersion: v1
|
| 401 |
+
kind: Service
|
| 402 |
+
metadata:
|
| 403 |
+
name: postgres-service
|
| 404 |
+
namespace: optimai-bre
|
| 405 |
+
spec:
|
| 406 |
+
clusterIP: None
|
| 407 |
+
selector:
|
| 408 |
+
app: postgres
|
| 409 |
+
ports:
|
| 410 |
+
- port: 5432
|
| 411 |
+
|
| 412 |
+
---
|
| 413 |
+
# ============================================================
|
| 414 |
+
# REDIS StatefulSet
|
| 415 |
+
# ============================================================
|
| 416 |
+
apiVersion: apps/v1
|
| 417 |
+
kind: StatefulSet
|
| 418 |
+
metadata:
|
| 419 |
+
name: redis
|
| 420 |
+
namespace: optimai-bre
|
| 421 |
+
spec:
|
| 422 |
+
serviceName: redis-service
|
| 423 |
+
replicas: 1
|
| 424 |
+
selector:
|
| 425 |
+
matchLabels:
|
| 426 |
+
app: redis
|
| 427 |
+
template:
|
| 428 |
+
metadata:
|
| 429 |
+
labels:
|
| 430 |
+
app: redis
|
| 431 |
+
spec:
|
| 432 |
+
containers:
|
| 433 |
+
- name: redis
|
| 434 |
+
image: redis:7-alpine
|
| 435 |
+
command:
|
| 436 |
+
- redis-server
|
| 437 |
+
- --requirepass
|
| 438 |
+
- $(REDIS_PASSWORD)
|
| 439 |
+
- --maxmemory
|
| 440 |
+
- 512mb
|
| 441 |
+
- --maxmemory-policy
|
| 442 |
+
- allkeys-lru
|
| 443 |
+
env:
|
| 444 |
+
- name: REDIS_PASSWORD
|
| 445 |
+
valueFrom:
|
| 446 |
+
secretKeyRef:
|
| 447 |
+
name: bre-secrets
|
| 448 |
+
key: redis-password
|
| 449 |
+
ports:
|
| 450 |
+
- containerPort: 6379
|
| 451 |
+
resources:
|
| 452 |
+
requests:
|
| 453 |
+
memory: "256Mi"
|
| 454 |
+
cpu: "100m"
|
| 455 |
+
limits:
|
| 456 |
+
memory: "512Mi"
|
| 457 |
+
cpu: "500m"
|
| 458 |
+
volumeMounts:
|
| 459 |
+
- name: redis-data
|
| 460 |
+
mountPath: /data
|
| 461 |
+
volumeClaimTemplates:
|
| 462 |
+
- metadata:
|
| 463 |
+
name: redis-data
|
| 464 |
+
spec:
|
| 465 |
+
accessModes: ["ReadWriteOnce"]
|
| 466 |
+
resources:
|
| 467 |
+
requests:
|
| 468 |
+
storage: 10Gi
|
| 469 |
+
|
| 470 |
+
---
|
| 471 |
+
apiVersion: v1
|
| 472 |
+
kind: Service
|
| 473 |
+
metadata:
|
| 474 |
+
name: redis-service
|
| 475 |
+
namespace: optimai-bre
|
| 476 |
+
spec:
|
| 477 |
+
clusterIP: None
|
| 478 |
+
selector:
|
| 479 |
+
app: redis
|
| 480 |
+
ports:
|
| 481 |
+
- port: 6379
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================================
|
| 2 |
+
# OPTIM AI BRE Engine — Production Docker Compose
|
| 3 |
+
# Single command deployment: docker compose up -d
|
| 4 |
+
#
|
| 5 |
+
# Architecture:
|
| 6 |
+
# nginx (port 80/443) → frontend (3000) + backend (8080)
|
| 7 |
+
# backend → postgres (5432) + redis (6379)
|
| 8 |
+
#
|
| 9 |
+
# All services run inside Docker. Nothing needs to be installed
|
| 10 |
+
# on the host except Docker Desktop / Docker Engine.
|
| 11 |
+
# ==============================================================
|
| 12 |
+
|
| 13 |
+
name: optim-ai-bre
|
| 14 |
+
|
| 15 |
+
# ==============================================================
|
| 16 |
+
# NETWORKS
|
| 17 |
+
# ==============================================================
|
| 18 |
+
networks:
|
| 19 |
+
bre-public:
|
| 20 |
+
driver: bridge
|
| 21 |
+
bre-internal:
|
| 22 |
+
driver: bridge
|
| 23 |
+
internal: true
|
| 24 |
+
|
| 25 |
+
# ==============================================================
|
| 26 |
+
# PERSISTENT VOLUMES
|
| 27 |
+
# ==============================================================
|
| 28 |
+
volumes:
|
| 29 |
+
postgres-data:
|
| 30 |
+
driver: local
|
| 31 |
+
name: bre_postgres_data
|
| 32 |
+
|
| 33 |
+
redis-data:
|
| 34 |
+
driver: local
|
| 35 |
+
name: bre_redis_data
|
| 36 |
+
|
| 37 |
+
backend-logs:
|
| 38 |
+
driver: local
|
| 39 |
+
name: bre_backend_logs
|
| 40 |
+
|
| 41 |
+
backend-reports:
|
| 42 |
+
driver: local
|
| 43 |
+
name: bre_backend_reports
|
| 44 |
+
|
| 45 |
+
nginx-logs:
|
| 46 |
+
driver: local
|
| 47 |
+
name: bre_nginx_logs
|
| 48 |
+
|
| 49 |
+
db-backups:
|
| 50 |
+
driver: local
|
| 51 |
+
name: bre_db_backups
|
| 52 |
+
|
| 53 |
+
# ==============================================================
|
| 54 |
+
# SERVICES
|
| 55 |
+
# ==============================================================
|
| 56 |
+
services:
|
| 57 |
+
|
| 58 |
+
# ------------------------------------------------------------
|
| 59 |
+
# NGINX — Reverse Proxy & SSL Termination
|
| 60 |
+
# Entry point for all traffic on port 80 (and 443 for HTTPS)
|
| 61 |
+
# Routes: /api/* → backend | /* → frontend
|
| 62 |
+
# ------------------------------------------------------------
|
| 63 |
+
bre-nginx:
|
| 64 |
+
image: nginx:${NGINX_VERSION:-1.25-alpine}
|
| 65 |
+
container_name: bre-nginx
|
| 66 |
+
restart: unless-stopped
|
| 67 |
+
ports:
|
| 68 |
+
- "80:80"
|
| 69 |
+
- "443:443"
|
| 70 |
+
volumes:
|
| 71 |
+
- ./docker/nginx.conf:/etc/nginx/nginx.conf:ro
|
| 72 |
+
- nginx-logs:/var/log/nginx
|
| 73 |
+
# SSL certificates (optional — uncomment when you have certs):
|
| 74 |
+
# - ./docker/ssl:/etc/nginx/ssl:ro
|
| 75 |
+
networks:
|
| 76 |
+
- bre-public
|
| 77 |
+
- bre-internal
|
| 78 |
+
depends_on:
|
| 79 |
+
bre-backend:
|
| 80 |
+
condition: service_healthy
|
| 81 |
+
bre-frontend:
|
| 82 |
+
condition: service_healthy
|
| 83 |
+
healthcheck:
|
| 84 |
+
test: ["CMD", "wget", "-qO-", "http://localhost/nginx-health"]
|
| 85 |
+
interval: 30s
|
| 86 |
+
timeout: 10s
|
| 87 |
+
retries: 3
|
| 88 |
+
start_period: 15s
|
| 89 |
+
logging:
|
| 90 |
+
driver: json-file
|
| 91 |
+
options:
|
| 92 |
+
max-size: "10m"
|
| 93 |
+
max-file: "5"
|
| 94 |
+
|
| 95 |
+
# ------------------------------------------------------------
|
| 96 |
+
# BACKEND — .NET 8 BRE Engine API
|
| 97 |
+
# Internal only — not exposed to host, nginx proxies to it
|
| 98 |
+
# ------------------------------------------------------------
|
| 99 |
+
bre-backend:
|
| 100 |
+
build:
|
| 101 |
+
context: ./src/backend
|
| 102 |
+
dockerfile: ../../docker/Dockerfile.backend
|
| 103 |
+
image: optim-ai-bre/backend:latest
|
| 104 |
+
container_name: bre-backend
|
| 105 |
+
restart: unless-stopped
|
| 106 |
+
networks:
|
| 107 |
+
- bre-internal
|
| 108 |
+
environment:
|
| 109 |
+
ASPNETCORE_ENVIRONMENT: Production
|
| 110 |
+
ASPNETCORE_URLS: http://+:8080
|
| 111 |
+
DOTNET_RUNNING_IN_CONTAINER: "true"
|
| 112 |
+
|
| 113 |
+
# Database — reads from .env file
|
| 114 |
+
ConnectionStrings__PostgreSQL: >-
|
| 115 |
+
Host=bre-postgres;Port=5432;
|
| 116 |
+
Database=${POSTGRES_DB:-optimai_bre};
|
| 117 |
+
Username=${POSTGRES_USER:-optimai};
|
| 118 |
+
Password=${POSTGRES_PASSWORD};
|
| 119 |
+
Pooling=true;Minimum Pool Size=5;Maximum Pool Size=100;
|
| 120 |
+
Connection Lifetime=300;Keepalive=60
|
| 121 |
+
ConnectionStrings__Redis: >-
|
| 122 |
+
bre-redis:6379,
|
| 123 |
+
password=${REDIS_PASSWORD},
|
| 124 |
+
abortConnect=false,
|
| 125 |
+
connectRetry=3,
|
| 126 |
+
connectTimeout=5000,
|
| 127 |
+
syncTimeout=5000
|
| 128 |
+
|
| 129 |
+
# JWT
|
| 130 |
+
Jwt__SecretKey: ${JWT_SECRET_KEY}
|
| 131 |
+
Jwt__Issuer: ${JWT_ISSUER:-OptimAI.BRE}
|
| 132 |
+
Jwt__Audience: ${JWT_AUDIENCE:-OptimAI.BRE.Clients}
|
| 133 |
+
Jwt__AccessTokenExpiryMinutes: ${JWT_ACCESS_TOKEN_EXPIRY_MINUTES:-480}
|
| 134 |
+
Jwt__RefreshTokenExpiryDays: ${JWT_REFRESH_TOKEN_EXPIRY_DAYS:-30}
|
| 135 |
+
|
| 136 |
+
# AI Service
|
| 137 |
+
AiOptions__UseAzureOpenAI: ${USE_AZURE_OPENAI:-false}
|
| 138 |
+
AiOptions__Endpoint: ${AZURE_OPENAI_ENDPOINT:-}
|
| 139 |
+
AiOptions__ApiKey: ${AZURE_OPENAI_KEY:-}
|
| 140 |
+
AiOptions__ModelName: ${AZURE_OPENAI_MODEL:-gpt-4o}
|
| 141 |
+
|
| 142 |
+
# CORS
|
| 143 |
+
AllowedOrigins__0: ${APP_DOMAIN:-http://localhost}
|
| 144 |
+
AllowedOrigins__1: http://bre-frontend:3000
|
| 145 |
+
|
| 146 |
+
# Rule Engine
|
| 147 |
+
RuleEngine__MaxConcurrentExecutions: ${RULE_ENGINE_MAX_CONCURRENT:-100}
|
| 148 |
+
RuleEngine__DefaultTimeoutMs: ${RULE_ENGINE_TIMEOUT_MS:-5000}
|
| 149 |
+
RuleEngine__EnableAiByDefault: "false"
|
| 150 |
+
RuleEngine__CacheRulesTtlMinutes: ${RULE_ENGINE_CACHE_TTL_MINUTES:-5}
|
| 151 |
+
|
| 152 |
+
# Serilog
|
| 153 |
+
Serilog__MinimumLevel__Default: ${LOG_LEVEL:-Information}
|
| 154 |
+
|
| 155 |
+
volumes:
|
| 156 |
+
- backend-logs:/app/logs
|
| 157 |
+
- backend-reports:/app/reports
|
| 158 |
+
depends_on:
|
| 159 |
+
bre-postgres:
|
| 160 |
+
condition: service_healthy
|
| 161 |
+
bre-redis:
|
| 162 |
+
condition: service_healthy
|
| 163 |
+
healthcheck:
|
| 164 |
+
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
| 165 |
+
interval: 30s
|
| 166 |
+
timeout: 15s
|
| 167 |
+
retries: 5
|
| 168 |
+
start_period: 60s
|
| 169 |
+
logging:
|
| 170 |
+
driver: json-file
|
| 171 |
+
options:
|
| 172 |
+
max-size: "20m"
|
| 173 |
+
max-file: "10"
|
| 174 |
+
|
| 175 |
+
# ------------------------------------------------------------
|
| 176 |
+
# FRONTEND — Next.js 14 UI
|
| 177 |
+
# Internal only — nginx proxies / to this service
|
| 178 |
+
# NEXT_PUBLIC_API_URL=/api/v1 → nginx routes to backend
|
| 179 |
+
# ------------------------------------------------------------
|
| 180 |
+
bre-frontend:
|
| 181 |
+
build:
|
| 182 |
+
context: ./src/frontend/optim-ai-bre-ui
|
| 183 |
+
dockerfile: ../../../docker/Dockerfile.frontend
|
| 184 |
+
args:
|
| 185 |
+
NEXT_PUBLIC_API_URL: /api/v1
|
| 186 |
+
image: optim-ai-bre/frontend:latest
|
| 187 |
+
container_name: bre-frontend
|
| 188 |
+
restart: unless-stopped
|
| 189 |
+
networks:
|
| 190 |
+
- bre-internal
|
| 191 |
+
environment:
|
| 192 |
+
NODE_ENV: production
|
| 193 |
+
NEXT_TELEMETRY_DISABLED: "1"
|
| 194 |
+
PORT: "3000"
|
| 195 |
+
HOSTNAME: "0.0.0.0"
|
| 196 |
+
healthcheck:
|
| 197 |
+
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
| 198 |
+
interval: 30s
|
| 199 |
+
timeout: 10s
|
| 200 |
+
retries: 3
|
| 201 |
+
start_period: 30s
|
| 202 |
+
logging:
|
| 203 |
+
driver: json-file
|
| 204 |
+
options:
|
| 205 |
+
max-size: "10m"
|
| 206 |
+
max-file: "5"
|
| 207 |
+
|
| 208 |
+
# ------------------------------------------------------------
|
| 209 |
+
# POSTGRESQL 15 — Primary Database
|
| 210 |
+
# Persistent volume: bre_postgres_data
|
| 211 |
+
# Init scripts auto-run on first empty volume start
|
| 212 |
+
# ------------------------------------------------------------
|
| 213 |
+
bre-postgres:
|
| 214 |
+
image: postgres:${POSTGRES_VERSION:-15-alpine}
|
| 215 |
+
container_name: bre-postgres
|
| 216 |
+
restart: unless-stopped
|
| 217 |
+
networks:
|
| 218 |
+
- bre-internal
|
| 219 |
+
environment:
|
| 220 |
+
POSTGRES_DB: ${POSTGRES_DB:-optimai_bre}
|
| 221 |
+
POSTGRES_USER: ${POSTGRES_USER:-optimai}
|
| 222 |
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
| 223 |
+
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
|
| 224 |
+
PGDATA: /var/lib/postgresql/data/pgdata
|
| 225 |
+
volumes:
|
| 226 |
+
# Persistent data volume
|
| 227 |
+
- postgres-data:/var/lib/postgresql/data
|
| 228 |
+
# Init scripts — run automatically on empty volume (first start)
|
| 229 |
+
- ./docker/postgres-init:/docker-entrypoint-initdb.d/init:ro
|
| 230 |
+
- ./database/migrations:/docker-entrypoint-initdb.d/migrations:ro
|
| 231 |
+
# Backup folder — mapped to host for easy backup access
|
| 232 |
+
- db-backups:/backups
|
| 233 |
+
healthcheck:
|
| 234 |
+
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-optimai} -d ${POSTGRES_DB:-optimai_bre}"]
|
| 235 |
+
interval: 10s
|
| 236 |
+
timeout: 5s
|
| 237 |
+
retries: 10
|
| 238 |
+
start_period: 30s
|
| 239 |
+
command: >
|
| 240 |
+
postgres
|
| 241 |
+
-c max_connections=200
|
| 242 |
+
-c shared_buffers=256MB
|
| 243 |
+
-c effective_cache_size=768MB
|
| 244 |
+
-c maintenance_work_mem=64MB
|
| 245 |
+
-c checkpoint_completion_target=0.9
|
| 246 |
+
-c wal_buffers=16MB
|
| 247 |
+
-c default_statistics_target=100
|
| 248 |
+
-c random_page_cost=1.1
|
| 249 |
+
-c effective_io_concurrency=200
|
| 250 |
+
-c work_mem=4MB
|
| 251 |
+
-c min_wal_size=1GB
|
| 252 |
+
-c max_wal_size=4GB
|
| 253 |
+
-c log_min_duration_statement=1000
|
| 254 |
+
-c log_line_prefix='%t [%p] %u@%d '
|
| 255 |
+
logging:
|
| 256 |
+
driver: json-file
|
| 257 |
+
options:
|
| 258 |
+
max-size: "10m"
|
| 259 |
+
max-file: "5"
|
| 260 |
+
|
| 261 |
+
# ------------------------------------------------------------
|
| 262 |
+
# REDIS 7 — Rule Cache & Session Store
|
| 263 |
+
# Persistent volume: bre_redis_data
|
| 264 |
+
# Password-protected in production
|
| 265 |
+
# ------------------------------------------------------------
|
| 266 |
+
bre-redis:
|
| 267 |
+
image: redis:${REDIS_VERSION:-7-alpine}
|
| 268 |
+
container_name: bre-redis
|
| 269 |
+
restart: unless-stopped
|
| 270 |
+
networks:
|
| 271 |
+
- bre-internal
|
| 272 |
+
command: >
|
| 273 |
+
redis-server
|
| 274 |
+
--requirepass ${REDIS_PASSWORD}
|
| 275 |
+
--maxmemory 512mb
|
| 276 |
+
--maxmemory-policy allkeys-lru
|
| 277 |
+
--save 900 1
|
| 278 |
+
--save 300 10
|
| 279 |
+
--save 60 10000
|
| 280 |
+
--appendonly yes
|
| 281 |
+
--appendfsync everysec
|
| 282 |
+
--loglevel notice
|
| 283 |
+
volumes:
|
| 284 |
+
- redis-data:/data
|
| 285 |
+
healthcheck:
|
| 286 |
+
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
| 287 |
+
interval: 10s
|
| 288 |
+
timeout: 5s
|
| 289 |
+
retries: 5
|
| 290 |
+
start_period: 10s
|
| 291 |
+
logging:
|
| 292 |
+
driver: json-file
|
| 293 |
+
options:
|
| 294 |
+
max-size: "5m"
|
| 295 |
+
max-file: "3"
|
| 296 |
+
|
| 297 |
+
# ------------------------------------------------------------
|
| 298 |
+
# DB BACKUP — Scheduled PostgreSQL backup (runs daily at 2AM)
|
| 299 |
+
# Backups stored in the db-backups volume
|
| 300 |
+
# Keep 7 days of backups
|
| 301 |
+
# ------------------------------------------------------------
|
| 302 |
+
bre-db-backup:
|
| 303 |
+
image: postgres:${POSTGRES_VERSION:-15-alpine}
|
| 304 |
+
container_name: bre-db-backup
|
| 305 |
+
restart: unless-stopped
|
| 306 |
+
networks:
|
| 307 |
+
- bre-internal
|
| 308 |
+
environment:
|
| 309 |
+
PGPASSWORD: ${POSTGRES_PASSWORD}
|
| 310 |
+
POSTGRES_DB: ${POSTGRES_DB:-optimai_bre}
|
| 311 |
+
POSTGRES_USER: ${POSTGRES_USER:-optimai}
|
| 312 |
+
volumes:
|
| 313 |
+
- db-backups:/backups
|
| 314 |
+
entrypoint: >
|
| 315 |
+
sh -c '
|
| 316 |
+
while true; do
|
| 317 |
+
TIMESTAMP=$$(date +%Y%m%d_%H%M%S)
|
| 318 |
+
BACKUP_FILE="/backups/bre_backup_$$TIMESTAMP.sql.gz"
|
| 319 |
+
echo "[$$TIMESTAMP] Starting database backup..."
|
| 320 |
+
pg_dump -h bre-postgres -U $$POSTGRES_USER -d $$POSTGRES_DB | gzip > $$BACKUP_FILE
|
| 321 |
+
echo "[$$TIMESTAMP] Backup saved: $$BACKUP_FILE"
|
| 322 |
+
# Delete backups older than 7 days
|
| 323 |
+
find /backups -name "bre_backup_*.sql.gz" -mtime +7 -delete
|
| 324 |
+
echo "[$$TIMESTAMP] Old backups cleaned. Sleeping 24h..."
|
| 325 |
+
sleep 86400
|
| 326 |
+
done
|
| 327 |
+
'
|
| 328 |
+
depends_on:
|
| 329 |
+
bre-postgres:
|
| 330 |
+
condition: service_healthy
|
| 331 |
+
logging:
|
| 332 |
+
driver: json-file
|
| 333 |
+
options:
|
| 334 |
+
max-size: "5m"
|
| 335 |
+
max-file: "3"
|
globals.css
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
@layer base {
|
| 6 |
+
:root {
|
| 7 |
+
--background: 0 0% 100%;
|
| 8 |
+
--foreground: 222.2 84% 4.9%;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
* {
|
| 12 |
+
box-sizing: border-box;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
html {
|
| 16 |
+
scroll-behavior: smooth;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
body {
|
| 20 |
+
@apply text-gray-900 antialiased;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/* Custom scrollbar */
|
| 24 |
+
::-webkit-scrollbar {
|
| 25 |
+
width: 6px;
|
| 26 |
+
height: 6px;
|
| 27 |
+
}
|
| 28 |
+
::-webkit-scrollbar-track {
|
| 29 |
+
@apply bg-gray-100;
|
| 30 |
+
}
|
| 31 |
+
::-webkit-scrollbar-thumb {
|
| 32 |
+
@apply bg-gray-300 rounded-full;
|
| 33 |
+
}
|
| 34 |
+
::-webkit-scrollbar-thumb:hover {
|
| 35 |
+
@apply bg-gray-400;
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
@layer components {
|
| 40 |
+
.card {
|
| 41 |
+
@apply bg-white rounded-xl border border-gray-200 shadow-sm;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.badge-green { @apply inline-flex px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700; }
|
| 45 |
+
.badge-red { @apply inline-flex px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700; }
|
| 46 |
+
.badge-amber { @apply inline-flex px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700; }
|
| 47 |
+
.badge-blue { @apply inline-flex px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-700; }
|
| 48 |
+
.badge-purple { @apply inline-flex px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-100 text-purple-700; }
|
| 49 |
+
|
| 50 |
+
.btn-primary {
|
| 51 |
+
@apply px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-semibold
|
| 52 |
+
hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
| 53 |
+
}
|
| 54 |
+
.btn-secondary {
|
| 55 |
+
@apply px-4 py-2 border border-gray-200 text-gray-700 rounded-lg text-sm font-medium
|
| 56 |
+
hover:bg-gray-50 transition-colors;
|
| 57 |
+
}
|
| 58 |
+
.btn-danger {
|
| 59 |
+
@apply px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-semibold
|
| 60 |
+
hover:bg-red-700 transition-colors;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.input {
|
| 64 |
+
@apply w-full border border-gray-200 rounded-lg px-3 py-2 text-sm
|
| 65 |
+
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
| 66 |
+
placeholder:text-gray-400;
|
| 67 |
+
}
|
| 68 |
+
}
|
index.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================
|
| 2 |
+
// OPTIM AI BRE ENGINE - TypeScript Types
|
| 3 |
+
// ============================================================
|
| 4 |
+
|
| 5 |
+
export type Decision = 'APPROVE' | 'REJECT' | 'DEVIATION' | 'REFER' | 'PENDING'
|
| 6 |
+
export type TrafficLight = 'GREEN' | 'AMBER' | 'RED'
|
| 7 |
+
export type RiskCategory = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
|
| 8 |
+
export type RuleStatus = 'DRAFT' | 'PENDING_APPROVAL' | 'APPROVED' | 'PUBLISHED' | 'ARCHIVED'
|
| 9 |
+
export type RuleType = 'ELIGIBILITY' | 'CREDIT' | 'BUREAU' | 'FI' | 'VALUATION' | 'FRAUD' | 'COMPLIANCE' | 'DEVIATION' | 'INCOME' | 'KYC' | 'VEHICLE' | 'GUARANTOR'
|
| 10 |
+
export type LogicalOperator = 'AND' | 'OR' | 'NOT'
|
| 11 |
+
export type ComparisonOperator = 'EQUALS' | 'NOT_EQUALS' | 'GREATER_THAN' | 'GREATER_THAN_OR_EQUAL' | 'LESS_THAN' | 'LESS_THAN_OR_EQUAL' | 'BETWEEN' | 'NOT_BETWEEN' | 'IN' | 'NOT_IN' | 'CONTAINS' | 'IS_NULL' | 'IS_NOT_NULL' | 'IS_TRUE' | 'IS_FALSE' | 'REGEX'
|
| 12 |
+
export type ActionType = 'SET_DECISION' | 'SET_RISK' | 'SET_TRAFFIC_LIGHT' | 'ADD_DEVIATION' | 'SET_FIELD' | 'ADD_TAG' | 'SET_SCORE'
|
| 13 |
+
export type Severity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
|
| 14 |
+
export type ScopeType = 'PRODUCT' | 'BRANCH' | 'STAGE' | 'USER_ROLE' | 'GLOBAL'
|
| 15 |
+
|
| 16 |
+
// ============================================================
|
| 17 |
+
// RULE ENGINE
|
| 18 |
+
// ============================================================
|
| 19 |
+
|
| 20 |
+
export interface ConditionGroup {
|
| 21 |
+
id: string
|
| 22 |
+
operator: LogicalOperator
|
| 23 |
+
rules: ConditionNode[]
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export interface ConditionNode {
|
| 27 |
+
id: string
|
| 28 |
+
isGroup: boolean
|
| 29 |
+
// Leaf condition
|
| 30 |
+
field?: string
|
| 31 |
+
operator?: ComparisonOperator
|
| 32 |
+
value?: string | number | boolean | string[]
|
| 33 |
+
value2?: string | number // for BETWEEN
|
| 34 |
+
valueType?: 'LITERAL' | 'FIELD' | 'FUNCTION'
|
| 35 |
+
referenceField?: string
|
| 36 |
+
// Group
|
| 37 |
+
group?: ConditionGroup
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export interface RuleAction {
|
| 41 |
+
id: string
|
| 42 |
+
type: ActionType
|
| 43 |
+
value?: string
|
| 44 |
+
field?: string
|
| 45 |
+
parameters?: Record<string, string>
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export interface RuleDefinition {
|
| 49 |
+
conditions: ConditionGroup
|
| 50 |
+
actions: RuleAction[]
|
| 51 |
+
metadata?: {
|
| 52 |
+
executionOrder?: number
|
| 53 |
+
stopOnMatch?: boolean
|
| 54 |
+
errorHandling?: 'SKIP' | 'FAIL' | 'USE_DEFAULT'
|
| 55 |
+
notes?: string
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export interface RuleScope {
|
| 60 |
+
scopeType: ScopeType
|
| 61 |
+
scopeValue: string
|
| 62 |
+
isExcluded: boolean
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export interface RuleVersion {
|
| 66 |
+
id: string
|
| 67 |
+
versionNumber: number
|
| 68 |
+
versionLabel: string
|
| 69 |
+
ruleDefinition: RuleDefinition
|
| 70 |
+
status: RuleStatus
|
| 71 |
+
changeSummary?: string
|
| 72 |
+
isCurrent: boolean
|
| 73 |
+
createdAt: string
|
| 74 |
+
approvedAt?: string
|
| 75 |
+
publishedAt?: string
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export interface Rule {
|
| 79 |
+
id: string
|
| 80 |
+
ruleCode: string
|
| 81 |
+
ruleName: string
|
| 82 |
+
description?: string
|
| 83 |
+
categoryId?: string
|
| 84 |
+
ruleType: RuleType
|
| 85 |
+
priority: number
|
| 86 |
+
isActive: boolean
|
| 87 |
+
isPublished: boolean
|
| 88 |
+
status: RuleStatus
|
| 89 |
+
tags: string[]
|
| 90 |
+
currentVersion?: RuleVersion
|
| 91 |
+
scopes: RuleScope[]
|
| 92 |
+
versionCount?: number
|
| 93 |
+
createdAt: string
|
| 94 |
+
updatedAt: string
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
export interface RuleCategory {
|
| 98 |
+
id: string
|
| 99 |
+
categoryCode: string
|
| 100 |
+
categoryName: string
|
| 101 |
+
description?: string
|
| 102 |
+
icon?: string
|
| 103 |
+
sortOrder: number
|
| 104 |
+
isActive: boolean
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export interface FieldCatalogEntry {
|
| 108 |
+
fieldPath: string
|
| 109 |
+
displayName: string
|
| 110 |
+
dataType: 'NUMBER' | 'STRING' | 'BOOLEAN' | 'DATE' | 'ARRAY' | 'OBJECT'
|
| 111 |
+
category?: string
|
| 112 |
+
description?: string
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// ============================================================
|
| 116 |
+
// EXECUTION
|
| 117 |
+
// ============================================================
|
| 118 |
+
|
| 119 |
+
export interface BREDecisionResponse {
|
| 120 |
+
requestId: string
|
| 121 |
+
correlationId: string
|
| 122 |
+
applicationId?: string
|
| 123 |
+
decision: Decision
|
| 124 |
+
trafficLight: TrafficLight
|
| 125 |
+
riskScore: number
|
| 126 |
+
riskCategory: RiskCategory
|
| 127 |
+
totalRulesEvaluated: number
|
| 128 |
+
rulesPassed: number
|
| 129 |
+
rulesFailed: number
|
| 130 |
+
deviationsCount: number
|
| 131 |
+
executionMs: number
|
| 132 |
+
ruleResults: RuleResult[]
|
| 133 |
+
aiSummary?: string
|
| 134 |
+
aiAnalysis?: AiAnalysis
|
| 135 |
+
reportId?: string
|
| 136 |
+
generatedAt: string
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
export interface RuleResult {
|
| 140 |
+
ruleCode: string
|
| 141 |
+
ruleName: string
|
| 142 |
+
isMatched: boolean
|
| 143 |
+
actionsExecuted: string[]
|
| 144 |
+
executionMs: number
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
export interface AiAnalysis {
|
| 148 |
+
riskSummary: string
|
| 149 |
+
creditSummary: string
|
| 150 |
+
strengths: string[]
|
| 151 |
+
weaknesses: string[]
|
| 152 |
+
deviationsSummary: string
|
| 153 |
+
approvalRecommendation: string
|
| 154 |
+
rejectionReasons: string[]
|
| 155 |
+
additionalDocuments: string[]
|
| 156 |
+
underwritingNotes: string
|
| 157 |
+
confidenceScore: number
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
export interface Deviation {
|
| 161 |
+
deviationCode: string
|
| 162 |
+
deviationName: string
|
| 163 |
+
severity: Severity
|
| 164 |
+
reason: string
|
| 165 |
+
fieldPath?: string
|
| 166 |
+
actualValue?: string
|
| 167 |
+
expectedValue?: string
|
| 168 |
+
recommendedAction?: string
|
| 169 |
+
isOverridden: boolean
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// ============================================================
|
| 173 |
+
// DASHBOARD
|
| 174 |
+
// ============================================================
|
| 175 |
+
|
| 176 |
+
export interface DashboardStats {
|
| 177 |
+
totalRules: number
|
| 178 |
+
activeRules: number
|
| 179 |
+
publishedRules: number
|
| 180 |
+
draftRules: number
|
| 181 |
+
totalExecutions: number
|
| 182 |
+
todayExecutions: number
|
| 183 |
+
approvalRate: number
|
| 184 |
+
rejectionRate: number
|
| 185 |
+
deviationRate: number
|
| 186 |
+
avgRiskScore: number
|
| 187 |
+
avgExecutionMs: number
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
export interface RuleHitAnalysis {
|
| 191 |
+
ruleCode: string
|
| 192 |
+
ruleName: string
|
| 193 |
+
totalEvaluations: number
|
| 194 |
+
matchedCount: number
|
| 195 |
+
hitRate: number
|
| 196 |
+
avgExecutionMs: number
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
export interface ExecutionTrend {
|
| 200 |
+
date: string
|
| 201 |
+
totalExecutions: number
|
| 202 |
+
approved: number
|
| 203 |
+
rejected: number
|
| 204 |
+
deviations: number
|
| 205 |
+
avgRiskScore: number
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
export interface DeviationAnalysisSummary {
|
| 209 |
+
deviationCode: string
|
| 210 |
+
deviationName: string
|
| 211 |
+
severity: Severity
|
| 212 |
+
occurrenceCount: number
|
| 213 |
+
overrideCount: number
|
| 214 |
+
month: string
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
// ============================================================
|
| 218 |
+
// CLIENT MANAGEMENT
|
| 219 |
+
// ============================================================
|
| 220 |
+
|
| 221 |
+
export interface Tenant {
|
| 222 |
+
id: string
|
| 223 |
+
tenantCode: string
|
| 224 |
+
tenantName: string
|
| 225 |
+
displayName?: string
|
| 226 |
+
logoUrl?: string
|
| 227 |
+
primaryColor: string
|
| 228 |
+
planType: 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE'
|
| 229 |
+
maxRules: number
|
| 230 |
+
maxExecutionsPerDay: number
|
| 231 |
+
isActive: boolean
|
| 232 |
+
subscriptionEndDate?: string
|
| 233 |
+
createdAt: string
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
export interface User {
|
| 237 |
+
id: string
|
| 238 |
+
tenantId: string
|
| 239 |
+
email: string
|
| 240 |
+
username: string
|
| 241 |
+
fullName: string
|
| 242 |
+
employeeId?: string
|
| 243 |
+
designation?: string
|
| 244 |
+
department?: string
|
| 245 |
+
mobile?: string
|
| 246 |
+
isActive: boolean
|
| 247 |
+
roles: string[]
|
| 248 |
+
lastLoginAt?: string
|
| 249 |
+
createdAt: string
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// ============================================================
|
| 253 |
+
// MISC
|
| 254 |
+
// ============================================================
|
| 255 |
+
|
| 256 |
+
export interface PagedResponse<T> {
|
| 257 |
+
items: T[]
|
| 258 |
+
totalCount: number
|
| 259 |
+
page: number
|
| 260 |
+
pageSize: number
|
| 261 |
+
totalPages: number
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
export interface ApiError {
|
| 265 |
+
status: number
|
| 266 |
+
title: string
|
| 267 |
+
detail?: string
|
| 268 |
+
errors?: Record<string, string[]>
|
| 269 |
+
}
|
layout.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from 'next'
|
| 2 |
+
import { Inter } from 'next/font/google'
|
| 3 |
+
import './globals.css'
|
| 4 |
+
import { Providers } from './providers'
|
| 5 |
+
import { AppShell } from '@/components/layout/AppShell'
|
| 6 |
+
|
| 7 |
+
const inter = Inter({ subsets: ['latin'] })
|
| 8 |
+
|
| 9 |
+
export const metadata: Metadata = {
|
| 10 |
+
title: 'OPTIM AI BRE Engine',
|
| 11 |
+
description: 'Enterprise AI-Powered Business Rule Engine for Banks and NBFCs',
|
| 12 |
+
icons: { icon: '/favicon.ico' },
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
| 16 |
+
return (
|
| 17 |
+
<html lang="en" suppressHydrationWarning>
|
| 18 |
+
<body className={`${inter.className} antialiased bg-gray-50`}>
|
| 19 |
+
<Providers>
|
| 20 |
+
<AppShell>{children}</AppShell>
|
| 21 |
+
</Providers>
|
| 22 |
+
</body>
|
| 23 |
+
</html>
|
| 24 |
+
)
|
| 25 |
+
}
|