zombee11 commited on
Commit
ec24e1d
·
verified ·
1 Parent(s): 6321846

Upload 59 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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
+ }