diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..05697538c9459d590f41ed08e77fb1693c747029 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore +.next +node_modules +npm-debug.log* +.env +.env.* +!.env.example +coverage +.nyc_output +.DS_Store +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..26c783eaeb543696a23a3c2eb03bb0ecd0701be4 --- /dev/null +++ b/.env.example @@ -0,0 +1,74 @@ +# ============================================================== +# OPTIM AI BRE Engine — Environment Variables +# STEP 1: Copy this file: cp .env.example .env +# STEP 2: Edit .env and change every CHANGE_ME value +# NEVER commit .env to version control +# ============================================================== + +# ============================================================== +# POSTGRESQL +# ============================================================== +POSTGRES_DB=optimai_bre +POSTGRES_USER=optimai +POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD_HERE + +# ============================================================== +# REDIS +# ============================================================== +REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD_HERE + +# ============================================================== +# JWT SECRET KEY +# Must be minimum 32 characters, random string. +# Generate one: +# Windows: -join ((65..90)+(97..122)+(48..57) | Get-Random -Count 64 | % {[char]$_}) +# Linux: openssl rand -base64 48 +# ============================================================== +JWT_SECRET_KEY=CHANGE_ME_MINIMUM_32_CHAR_RANDOM_STRING_FOR_PRODUCTION + +JWT_ISSUER=OptimAI.BRE +JWT_AUDIENCE=OptimAI.BRE.Clients +JWT_ACCESS_TOKEN_EXPIRY_MINUTES=480 +JWT_REFRESH_TOKEN_EXPIRY_DAYS=30 + +# ============================================================== +# AI SERVICE (Optional — BRE works without this) +# Leave AZURE_OPENAI_KEY blank to disable AI features. +# +# Option A — Azure OpenAI: +# USE_AZURE_OPENAI=true +# AZURE_OPENAI_ENDPOINT=https://YOUR-RESOURCE.openai.azure.com/ +# AZURE_OPENAI_KEY=your-azure-openai-key +# +# Option B — OpenAI: +# USE_AZURE_OPENAI=false +# AZURE_OPENAI_KEY=sk-your-openai-key-here +# ============================================================== +USE_AZURE_OPENAI=false +AZURE_OPENAI_ENDPOINT= +AZURE_OPENAI_KEY= +AZURE_OPENAI_MODEL=gpt-4o + +# ============================================================== +# APPLICATION DOMAIN +# Used for CORS allowed origins. +# Set to your server IP or domain. Examples: +# http://192.168.1.100 +# https://bre.yourcompany.com +# ============================================================== +APP_DOMAIN=http://localhost + +# ============================================================== +# RULE ENGINE SETTINGS +# ============================================================== +RULE_ENGINE_MAX_CONCURRENT=100 +RULE_ENGINE_TIMEOUT_MS=5000 +RULE_ENGINE_CACHE_TTL_MINUTES=5 +LOG_LEVEL=Information + +# ============================================================== +# POSTGRES VERSIONS (pin for reproducibility) +# ============================================================== +POSTGRES_VERSION=15-alpine +REDIS_VERSION=7-alpine +NGINX_VERSION=1.25-alpine diff --git a/001_initial_schema.sql b/001_initial_schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..ff76750e73a556f88d157775135f5c743ed6c8e0 --- /dev/null +++ b/001_initial_schema.sql @@ -0,0 +1,827 @@ +-- ============================================================ +-- OPTIM AI BRE ENGINE - COMPLETE DATABASE SCHEMA +-- PostgreSQL 15+ +-- Multi-Tenant, Production-Grade +-- ============================================================ + +-- Enable extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- ============================================================ +-- SCHEMA: TENANTS & MULTI-TENANCY +-- ============================================================ + +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_code VARCHAR(50) UNIQUE NOT NULL, + tenant_name VARCHAR(200) NOT NULL, + display_name VARCHAR(200), + logo_url TEXT, + primary_color VARCHAR(10) DEFAULT '#1E40AF', + secondary_color VARCHAR(10) DEFAULT '#3B82F6', + plan_type VARCHAR(50) NOT NULL DEFAULT 'ENTERPRISE', -- STARTER, PROFESSIONAL, ENTERPRISE + max_rules INTEGER NOT NULL DEFAULT 1000, + max_executions_per_day BIGINT NOT NULL DEFAULT 1000000, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + trial_end_date TIMESTAMPTZ, + subscription_end_date TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID, + settings JSONB NOT NULL DEFAULT '{}'::JSONB +); + +CREATE TABLE tenant_configurations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + config_key VARCHAR(200) NOT NULL, + config_value TEXT, + config_type VARCHAR(50) NOT NULL DEFAULT 'STRING', -- STRING, JSON, BOOLEAN, NUMBER + category VARCHAR(100), + is_encrypted BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, config_key) +); + +-- ============================================================ +-- SCHEMA: IDENTITY & ACCESS MANAGEMENT +-- ============================================================ + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email VARCHAR(320) NOT NULL, + username VARCHAR(100) NOT NULL, + password_hash TEXT NOT NULL, + full_name VARCHAR(200) NOT NULL, + employee_id VARCHAR(100), + designation VARCHAR(200), + department VARCHAR(200), + mobile VARCHAR(20), + profile_image_url TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_email_verified BOOLEAN NOT NULL DEFAULT FALSE, + is_mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE, + mfa_secret TEXT, + last_login_at TIMESTAMPTZ, + last_login_ip INET, + failed_login_count INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + password_changed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, email), + UNIQUE(tenant_id, username) +); + +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + role_code VARCHAR(100) NOT NULL, + role_name VARCHAR(200) NOT NULL, + description TEXT, + is_system_role BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, role_code) +); + +CREATE TABLE permissions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + permission_code VARCHAR(200) UNIQUE NOT NULL, + permission_name VARCHAR(200) NOT NULL, + module VARCHAR(100) NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE role_permissions ( + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + granted_by UUID, + PRIMARY KEY (role_id, permission_id) +); + +CREATE TABLE user_roles ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + assigned_by UUID, + PRIMARY KEY (user_id, role_id) +); + +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + key_name VARCHAR(200) NOT NULL, + api_key_hash TEXT NOT NULL UNIQUE, + api_key_prefix VARCHAR(20) NOT NULL, + scopes TEXT[] NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + rate_limit_per_minute INTEGER NOT NULL DEFAULT 1000, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + is_revoked BOOLEAN NOT NULL DEFAULT FALSE, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================ +-- SCHEMA: PRODUCT & BRANCH CONFIGURATION +-- ============================================================ + +CREATE TABLE products ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + product_code VARCHAR(100) NOT NULL, + product_name VARCHAR(200) NOT NULL, + product_type VARCHAR(100) NOT NULL, -- VEHICLE_LOAN, TRACTOR_LOAN, AUTO_LOAN, CV_LOAN, MSME, HOME_LOAN, PERSONAL_LOAN + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + config JSONB NOT NULL DEFAULT '{}'::JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, product_code) +); + +CREATE TABLE branches ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + branch_code VARCHAR(100) NOT NULL, + branch_name VARCHAR(200) NOT NULL, + region VARCHAR(100), + state VARCHAR(100), + city VARCHAR(100), + zone VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, branch_code) +); + +CREATE TABLE loan_stages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + stage_code VARCHAR(100) NOT NULL, + stage_name VARCHAR(200) NOT NULL, + stage_order INTEGER NOT NULL DEFAULT 0, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, stage_code) +); + +-- ============================================================ +-- SCHEMA: RULE ENGINE CORE +-- ============================================================ + +CREATE TABLE rule_categories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + category_code VARCHAR(100) NOT NULL, + category_name VARCHAR(200) NOT NULL, + description TEXT, + icon VARCHAR(100), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, category_code) +); + +CREATE TABLE rules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + rule_code VARCHAR(200) NOT NULL, + rule_name VARCHAR(500) NOT NULL, + description TEXT, + category_id UUID REFERENCES rule_categories(id), + rule_type VARCHAR(100) NOT NULL, -- ELIGIBILITY, CREDIT, BUREAU, FI, VALUATION, FRAUD, COMPLIANCE, DEVIATION, INCOME, KYC + priority INTEGER NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_published BOOLEAN NOT NULL DEFAULT FALSE, + is_draft BOOLEAN NOT NULL DEFAULT TRUE, + status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', -- DRAFT, PENDING_APPROVAL, APPROVED, PUBLISHED, ARCHIVED + current_version_id UUID, + tags TEXT[] DEFAULT '{}', + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, rule_code) +); + +CREATE TABLE rule_versions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rule_id UUID NOT NULL REFERENCES rules(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + version_label VARCHAR(50), -- e.g., v1.0, v1.1 + rule_definition JSONB NOT NULL, -- Complete rule AST + change_summary TEXT, + is_current BOOLEAN NOT NULL DEFAULT FALSE, + status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', + approved_by UUID REFERENCES users(id), + approved_at TIMESTAMPTZ, + published_by UUID REFERENCES users(id), + published_at TIMESTAMPTZ, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(rule_id, version_number) +); + +-- Rule Definition JSON Structure (stored in rule_versions.rule_definition): +-- { +-- "conditions": { +-- "operator": "AND|OR", +-- "rules": [ +-- { +-- "id": "uuid", +-- "field": "bureau_score", +-- "operator": "LESS_THAN", +-- "value": 650, +-- "valueType": "NUMBER" +-- }, +-- { +-- "operator": "OR", +-- "rules": [...] -- nested group +-- } +-- ] +-- }, +-- "actions": [ +-- { "type": "SET_DECISION", "value": "REJECT" }, +-- { "type": "SET_RISK", "value": "HIGH" }, +-- { "type": "ADD_DEVIATION", "value": "LOW_BUREAU_SCORE" }, +-- { "type": "SET_FIELD", "field": "recommendation", "value": "Reject" } +-- ], +-- "metadata": { +-- "executionOrder": 1, +-- "stopOnMatch": true, +-- "errorHandling": "SKIP" +-- } +-- } + +CREATE TABLE rule_scopes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rule_id UUID NOT NULL REFERENCES rules(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + scope_type VARCHAR(50) NOT NULL, -- PRODUCT, BRANCH, STAGE, USER_ROLE, GLOBAL + scope_value VARCHAR(200) NOT NULL, -- product_code, branch_code, stage_code, role_code, or '*' for all + is_excluded BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE rule_sets ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + set_code VARCHAR(200) NOT NULL, + set_name VARCHAR(500) NOT NULL, + description TEXT, + execution_mode VARCHAR(50) NOT NULL DEFAULT 'ALL', -- ALL, FIRST_MATCH, SCORED + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, set_code) +); + +CREATE TABLE rule_set_members ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + set_id UUID NOT NULL REFERENCES rule_sets(id) ON DELETE CASCADE, + rule_id UUID NOT NULL REFERENCES rules(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + weight DECIMAL(5,2) DEFAULT 1.0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(set_id, rule_id) +); + +-- ============================================================ +-- SCHEMA: FIELD CATALOG (Dynamic Field Mapping) +-- ============================================================ + +CREATE TABLE field_catalog ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = global + field_path VARCHAR(500) NOT NULL, -- e.g., "bureau.cibil_score", "applicant.age" + display_name VARCHAR(200) NOT NULL, + description TEXT, + data_type VARCHAR(50) NOT NULL, -- NUMBER, STRING, BOOLEAN, DATE, ARRAY, OBJECT + category VARCHAR(100), -- BUREAU, INCOME, PERSONAL, VEHICLE, etc. + sample_values JSONB, + validation_rules JSONB, + is_system_field BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, field_path) +); + +CREATE TABLE custom_functions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + function_name VARCHAR(200) NOT NULL, + display_name VARCHAR(200) NOT NULL, + description TEXT, + category VARCHAR(100), + return_type VARCHAR(50) NOT NULL, + parameters JSONB NOT NULL DEFAULT '[]'::JSONB, + implementation TEXT NOT NULL, -- C# expression or script + is_system_fn BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, function_name) +); + +-- ============================================================ +-- SCHEMA: EXECUTION ENGINE +-- ============================================================ + +CREATE TABLE execution_requests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + correlation_id VARCHAR(200) UNIQUE, + application_id VARCHAR(200), + product_code VARCHAR(100), + branch_code VARCHAR(100), + stage_code VARCHAR(100), + rule_set_id UUID REFERENCES rule_sets(id), + input_payload JSONB NOT NULL, + input_hash VARCHAR(64), + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, PROCESSING, COMPLETED, FAILED + priority INTEGER NOT NULL DEFAULT 5, + source_system VARCHAR(200), + api_key_id UUID REFERENCES api_keys(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processing_started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + processing_ms INTEGER +); + +CREATE TABLE execution_results ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + request_id UUID NOT NULL REFERENCES execution_requests(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id), + final_decision VARCHAR(50) NOT NULL, -- APPROVE, REJECT, DEVIATION, REFER, PENDING + risk_score DECIMAL(5,2), + risk_category VARCHAR(50), -- LOW, MEDIUM, HIGH, CRITICAL + traffic_light VARCHAR(10), -- GREEN, AMBER, RED + total_rules_evaluated INTEGER NOT NULL DEFAULT 0, + rules_passed INTEGER NOT NULL DEFAULT 0, + rules_failed INTEGER NOT NULL DEFAULT 0, + rules_skipped INTEGER NOT NULL DEFAULT 0, + deviations_count INTEGER NOT NULL DEFAULT 0, + execution_ms INTEGER, + rule_results JSONB NOT NULL DEFAULT '[]'::JSONB, + field_values JSONB NOT NULL DEFAULT '{}'::JSONB, + ai_summary TEXT, + ai_analysis JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE rule_execution_details ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + result_id UUID NOT NULL REFERENCES execution_results(id) ON DELETE CASCADE, + rule_id UUID NOT NULL REFERENCES rules(id), + rule_code VARCHAR(200) NOT NULL, + rule_name VARCHAR(500) NOT NULL, + version_number INTEGER NOT NULL, + execution_order INTEGER NOT NULL, + is_matched BOOLEAN NOT NULL, + conditions_evaluated JSONB NOT NULL DEFAULT '[]'::JSONB, + actions_executed JSONB NOT NULL DEFAULT '[]'::JSONB, + execution_ms INTEGER, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================ +-- SCHEMA: DEVIATIONS +-- ============================================================ + +CREATE TABLE deviation_types ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + deviation_code VARCHAR(200) NOT NULL, + deviation_name VARCHAR(500) NOT NULL, + category VARCHAR(100), + default_severity VARCHAR(50) NOT NULL DEFAULT 'MEDIUM', -- LOW, MEDIUM, HIGH, CRITICAL + description TEXT, + recommended_action TEXT, + requires_approval BOOLEAN NOT NULL DEFAULT FALSE, + approver_role VARCHAR(200), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, deviation_code) +); + +CREATE TABLE execution_deviations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + result_id UUID NOT NULL REFERENCES execution_results(id) ON DELETE CASCADE, + rule_id UUID REFERENCES rules(id), + deviation_type_id UUID REFERENCES deviation_types(id), + deviation_code VARCHAR(200) NOT NULL, + deviation_name VARCHAR(500) NOT NULL, + severity VARCHAR(50) NOT NULL, + reason TEXT NOT NULL, + field_path VARCHAR(500), + actual_value TEXT, + expected_value TEXT, + recommended_action TEXT, + is_overridden BOOLEAN NOT NULL DEFAULT FALSE, + overridden_by UUID REFERENCES users(id), + override_reason TEXT, + override_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================ +-- SCHEMA: DECISION REPORTS +-- ============================================================ + +CREATE TABLE decision_reports ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + request_id UUID NOT NULL UNIQUE REFERENCES execution_requests(id), + tenant_id UUID NOT NULL REFERENCES tenants(id), + report_number VARCHAR(100) UNIQUE, + application_id VARCHAR(200), + product_code VARCHAR(100), + final_decision VARCHAR(50) NOT NULL, + risk_score DECIMAL(5,2), + risk_category VARCHAR(50), + traffic_light VARCHAR(10), + summary TEXT, + strengths TEXT[], + weaknesses TEXT[], + deviations_summary TEXT, + approval_recommendation TEXT, + rejection_reasons TEXT[], + additional_docs TEXT[], + underwriting_notes TEXT, + report_json JSONB, + pdf_storage_path TEXT, + excel_storage_path TEXT, + generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ +); + +-- ============================================================ +-- SCHEMA: SANDBOX & TESTING +-- ============================================================ + +CREATE TABLE sandbox_sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + session_name VARCHAR(200) NOT NULL, + rule_set_id UUID REFERENCES rule_sets(id), + test_payload JSONB NOT NULL, + result JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + executed_at TIMESTAMPTZ +); + +-- ============================================================ +-- SCHEMA: AI ENGINE +-- ============================================================ + +CREATE TABLE ai_prompts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + prompt_code VARCHAR(200) NOT NULL, + prompt_name VARCHAR(300) NOT NULL, + prompt_template TEXT NOT NULL, + model VARCHAR(100) DEFAULT 'gpt-4o', + temperature DECIMAL(3,2) DEFAULT 0.3, + max_tokens INTEGER DEFAULT 2000, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, prompt_code) +); + +CREATE TABLE ai_generated_rules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_prompt TEXT NOT NULL, + generated_rule JSONB NOT NULL, + rule_id UUID REFERENCES rules(id), + model_used VARCHAR(100), + tokens_used INTEGER, + is_accepted BOOLEAN, + feedback TEXT, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================ +-- SCHEMA: AUDIT & COMPLIANCE +-- ============================================================ + +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + user_id UUID REFERENCES users(id), + action VARCHAR(200) NOT NULL, + entity_type VARCHAR(100) NOT NULL, + entity_id VARCHAR(200), + old_values JSONB, + new_values JSONB, + ip_address INET, + user_agent TEXT, + request_id VARCHAR(200), + is_success BOOLEAN NOT NULL DEFAULT TRUE, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE rule_approvals ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rule_id UUID NOT NULL REFERENCES rules(id) ON DELETE CASCADE, + version_id UUID NOT NULL REFERENCES rule_versions(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id), + requested_by UUID NOT NULL REFERENCES users(id), + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_by UUID REFERENCES users(id), + reviewed_at TIMESTAMPTZ, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED + comments TEXT, + notification_sent BOOLEAN NOT NULL DEFAULT FALSE +); + +-- ============================================================ +-- SCHEMA: MARKETPLACE +-- ============================================================ + +CREATE TABLE marketplace_rules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rule_code VARCHAR(200) UNIQUE NOT NULL, + rule_name VARCHAR(500) NOT NULL, + description TEXT, + category VARCHAR(100), + product_types TEXT[], + rule_definition JSONB NOT NULL, + author VARCHAR(200), + version VARCHAR(50), + downloads INTEGER NOT NULL DEFAULT 0, + rating DECIMAL(3,2) DEFAULT 0, + tags TEXT[], + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + published_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE marketplace_imports ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + marketplace_rule_id UUID NOT NULL REFERENCES marketplace_rules(id), + imported_rule_id UUID REFERENCES rules(id), + imported_by UUID NOT NULL REFERENCES users(id), + imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================ +-- INDEXES FOR PERFORMANCE +-- ============================================================ + +-- Tenant isolation indexes +CREATE INDEX idx_rules_tenant ON rules(tenant_id) WHERE is_active = TRUE; +CREATE INDEX idx_rules_tenant_category ON rules(tenant_id, category_id) WHERE is_active = TRUE; +CREATE INDEX idx_rules_tenant_type ON rules(tenant_id, rule_type) WHERE is_active = TRUE AND is_published = TRUE; +CREATE INDEX idx_rule_scopes_rule ON rule_scopes(rule_id); +CREATE INDEX idx_rule_scopes_tenant_type ON rule_scopes(tenant_id, scope_type, scope_value); + +-- Execution performance indexes +CREATE INDEX idx_exec_requests_tenant_status ON execution_requests(tenant_id, status, created_at DESC); +CREATE INDEX idx_exec_requests_correlation ON execution_requests(correlation_id); +CREATE INDEX idx_exec_results_request ON execution_results(request_id); +CREATE INDEX idx_exec_results_tenant_decision ON execution_results(tenant_id, final_decision, created_at DESC); +CREATE INDEX idx_rule_exec_details_result ON rule_execution_details(result_id); + +-- Audit indexes +CREATE INDEX idx_audit_tenant_entity ON audit_logs(tenant_id, entity_type, entity_id, created_at DESC); +CREATE INDEX idx_audit_tenant_user ON audit_logs(tenant_id, user_id, created_at DESC); + +-- User lookup +CREATE INDEX idx_users_tenant_email ON users(tenant_id, email) WHERE is_active = TRUE; +CREATE INDEX idx_users_tenant_username ON users(tenant_id, username) WHERE is_active = TRUE; + +-- Field catalog full-text +CREATE INDEX idx_field_catalog_path ON field_catalog USING GIN(to_tsvector('english', field_path || ' ' || display_name)); + +-- Execution date range queries +CREATE INDEX idx_exec_requests_tenant_date ON execution_requests(tenant_id, created_at DESC); +CREATE INDEX idx_decision_reports_tenant ON decision_reports(tenant_id, generated_at DESC); + +-- ============================================================ +-- SEED DATA: SYSTEM PERMISSIONS +-- ============================================================ + +INSERT INTO permissions (permission_code, permission_name, module, description) VALUES +-- Rule Management +('RULE.VIEW', 'View Rules', 'RULE_ENGINE', 'View all rules'), +('RULE.CREATE', 'Create Rules', 'RULE_ENGINE', 'Create new rules'), +('RULE.EDIT', 'Edit Rules', 'RULE_ENGINE', 'Edit existing rules'), +('RULE.DELETE', 'Delete Rules', 'RULE_ENGINE', 'Delete rules'), +('RULE.PUBLISH', 'Publish Rules', 'RULE_ENGINE', 'Publish rules to production'), +('RULE.APPROVE', 'Approve Rules', 'RULE_ENGINE', 'Approve rule changes'), +('RULE.CLONE', 'Clone Rules', 'RULE_ENGINE', 'Clone existing rules'), +('RULE.VERSION', 'Manage Rule Versions', 'RULE_ENGINE', 'View and manage rule versions'), +-- Execution +('EXECUTION.VIEW', 'View Executions', 'EXECUTION', 'View execution results'), +('EXECUTION.EXECUTE', 'Execute Rules', 'EXECUTION', 'Execute rule engine via API'), +('EXECUTION.SANDBOX', 'Use Sandbox', 'EXECUTION', 'Use sandbox testing environment'), +-- Client Management +('CLIENT.VIEW', 'View Clients', 'CLIENT_MGMT', 'View client list'), +('CLIENT.CREATE', 'Create Clients', 'CLIENT_MGMT', 'Create new clients'), +('CLIENT.EDIT', 'Edit Clients', 'CLIENT_MGMT', 'Edit client configuration'), +('CLIENT.MANAGE_USERS', 'Manage Client Users', 'CLIENT_MGMT', 'Manage users within tenant'), +-- Reports +('REPORT.VIEW', 'View Reports', 'REPORTS', 'View decision reports'), +('REPORT.EXPORT', 'Export Reports', 'REPORTS', 'Export reports to PDF/Excel'), +-- AI Engine +('AI.GENERATE', 'AI Rule Generation', 'AI_ENGINE', 'Use AI to generate rules'), +('AI.ANALYSIS', 'AI Credit Analysis', 'AI_ENGINE', 'Access AI credit analysis'), +-- Admin +('ADMIN.FULL', 'Full Admin Access', 'ADMIN', 'Complete administrative access'), +('AUDIT.VIEW', 'View Audit Logs', 'AUDIT', 'Access audit trail'); + +-- ============================================================ +-- SEED DATA: GLOBAL FIELD CATALOG +-- ============================================================ + +INSERT INTO field_catalog (field_path, display_name, data_type, category, is_system_field) VALUES +-- Personal +('applicant.age', 'Applicant Age', 'NUMBER', 'PERSONAL', TRUE), +('applicant.date_of_birth', 'Date of Birth', 'DATE', 'PERSONAL', TRUE), +('applicant.gender', 'Gender', 'STRING', 'PERSONAL', TRUE), +('applicant.pan_number', 'PAN Number', 'STRING', 'KYC', TRUE), +('applicant.aadhaar_number', 'Aadhaar Number', 'STRING', 'KYC', TRUE), +('applicant.mobile', 'Mobile Number', 'STRING', 'PERSONAL', TRUE), +('applicant.email', 'Email Address', 'STRING', 'PERSONAL', TRUE), +('applicant.marital_status', 'Marital Status', 'STRING', 'PERSONAL', TRUE), +('applicant.caste_category', 'Caste Category', 'STRING', 'PERSONAL', TRUE), +-- Employment +('employment.type', 'Employment Type', 'STRING', 'EMPLOYMENT', TRUE), +('employment.employer_name', 'Employer Name', 'STRING', 'EMPLOYMENT', TRUE), +('employment.monthly_income', 'Monthly Income', 'NUMBER', 'INCOME', TRUE), +('employment.annual_income', 'Annual Income', 'NUMBER', 'INCOME', TRUE), +('employment.vintage_months', 'Employment Vintage (Months)', 'NUMBER', 'EMPLOYMENT', TRUE), +-- Bureau +('bureau.cibil_score', 'CIBIL Score', 'NUMBER', 'BUREAU', TRUE), +('bureau.experian_score', 'Experian Score', 'NUMBER', 'BUREAU', TRUE), +('bureau.equifax_score', 'Equifax Score', 'NUMBER', 'BUREAU', TRUE), +('bureau.crif_score', 'CRIF Score', 'NUMBER', 'BUREAU', TRUE), +('bureau.max_dpd_24m', 'Max DPD (24 Months)', 'NUMBER', 'BUREAU', TRUE), +('bureau.max_dpd_12m', 'Max DPD (12 Months)', 'NUMBER', 'BUREAU', TRUE), +('bureau.total_active_loans', 'Total Active Loans', 'NUMBER', 'BUREAU', TRUE), +('bureau.unsecured_outstanding', 'Unsecured Outstanding Amount', 'NUMBER', 'BUREAU', TRUE), +('bureau.secured_outstanding', 'Secured Outstanding Amount', 'NUMBER', 'BUREAU', TRUE), +('bureau.total_emi_obligation', 'Total EMI Obligation', 'NUMBER', 'BUREAU', TRUE), +('bureau.written_off_amount', 'Written Off Amount', 'NUMBER', 'BUREAU', TRUE), +('bureau.suit_filed', 'Suit Filed', 'BOOLEAN', 'BUREAU', TRUE), +('bureau.wilful_defaulter', 'Wilful Defaulter', 'BOOLEAN', 'BUREAU', TRUE), +-- Income Ratios +('ratios.foir', 'FOIR (Fixed Obligation to Income Ratio)', 'NUMBER', 'RATIOS', TRUE), +('ratios.ltv', 'LTV (Loan to Value Ratio)', 'NUMBER', 'RATIOS', TRUE), +('ratios.dscr', 'DSCR (Debt Service Coverage Ratio)', 'NUMBER', 'RATIOS', TRUE), +-- Loan +('loan.amount', 'Loan Amount', 'NUMBER', 'LOAN', TRUE), +('loan.tenure_months', 'Loan Tenure (Months)', 'NUMBER', 'LOAN', TRUE), +('loan.emi', 'EMI Amount', 'NUMBER', 'LOAN', TRUE), +('loan.interest_rate', 'Interest Rate', 'NUMBER', 'LOAN', TRUE), +('loan.purpose', 'Loan Purpose', 'STRING', 'LOAN', TRUE), +-- Vehicle +('vehicle.type', 'Vehicle Type', 'STRING', 'VEHICLE', TRUE), +('vehicle.make', 'Vehicle Make', 'STRING', 'VEHICLE', TRUE), +('vehicle.model', 'Vehicle Model', 'STRING', 'VEHICLE', TRUE), +('vehicle.year', 'Vehicle Year', 'NUMBER', 'VEHICLE', TRUE), +('vehicle.age_years', 'Vehicle Age (Years)', 'NUMBER', 'VEHICLE', TRUE), +('vehicle.valuation', 'Vehicle Valuation', 'NUMBER', 'VEHICLE', TRUE), +('vehicle.registration_number', 'Registration Number', 'STRING', 'VEHICLE', TRUE), +-- FI +('fi.verified', 'FI Verified', 'BOOLEAN', 'FI', TRUE), +('fi.residence_type', 'Residence Type', 'STRING', 'FI', TRUE), +('fi.address_match', 'Address Match', 'BOOLEAN', 'FI', TRUE), +('fi.mobile_match', 'Mobile Match', 'BOOLEAN', 'FI', TRUE), +('fi.negative', 'FI Negative', 'BOOLEAN', 'FI', TRUE), +-- GST & ITR +('gst.turnover', 'GST Turnover', 'NUMBER', 'GST', TRUE), +('gst.filing_months', 'GST Filing Months', 'NUMBER', 'GST', TRUE), +('itr.gross_income', 'ITR Gross Income', 'NUMBER', 'ITR', TRUE), +('itr.net_income', 'ITR Net Income', 'NUMBER', 'ITR', TRUE), +('itr.years_filed', 'ITR Years Filed', 'NUMBER', 'ITR', TRUE), +-- Fraud +('fraud.score', 'Fraud Score', 'NUMBER', 'FRAUD', TRUE), +('fraud.blacklisted', 'Blacklisted', 'BOOLEAN', 'FRAUD', TRUE), +('fraud.device_score', 'Device Risk Score', 'NUMBER', 'FRAUD', TRUE); + +-- ============================================================ +-- SEED DATA: DEVIATION TYPES +-- ============================================================ + +INSERT INTO deviation_types (deviation_code, deviation_name, category, default_severity, description, recommended_action) VALUES +('LOW_BUREAU_SCORE', 'Low Bureau Score', 'BUREAU', 'HIGH', 'Bureau/CIBIL score below threshold', 'Obtain credit counseling letter or additional collateral'), +('HIGH_DPD', 'High DPD', 'BUREAU', 'HIGH', 'Days Past Due exceeds acceptable limit', 'Explain previous defaults with supporting docs'), +('HIGH_FOIR', 'High FOIR', 'INCOME', 'MEDIUM', 'Fixed Obligation to Income Ratio exceeds limit', 'Consider reducing loan amount or tenure'), +('INCOME_MISMATCH', 'Income Mismatch', 'INCOME', 'MEDIUM', 'Stated income does not match verified income', 'Provide additional income proof documents'), +('NEGATIVE_FI', 'Negative FI Report', 'FI', 'HIGH', 'Field Investigation report is negative', 'Second FI verification required'), +('ADDRESS_MISMATCH', 'Address Mismatch', 'FI', 'MEDIUM', 'Applicant address does not match records', 'Obtain additional address proof'), +('MOBILE_MISMATCH', 'Mobile Number Mismatch', 'FI', 'LOW', 'Mobile number not matching bureau records', 'Verify mobile ownership'), +('HIGH_LTV', 'High LTV', 'VALUATION', 'MEDIUM', 'Loan to Value ratio exceeds limit', 'Additional down payment required'), +('VEHICLE_AGE', 'Vehicle Age Deviation', 'VEHICLE', 'MEDIUM', 'Vehicle age exceeds permissible limit', 'Obtain extended warranty or reduce tenure'), +('MULTIPLE_LOANS', 'Multiple Active Loans', 'BUREAU', 'MEDIUM', 'High number of active loans', 'Review repayment capacity'), +('WRITTEN_OFF', 'Written Off Account', 'BUREAU', 'CRITICAL', 'Previous written off account found', 'NOC from previous lender required'), +('SUIT_FILED', 'Suit Filed', 'BUREAU', 'CRITICAL', 'Legal suit filed against applicant', 'Legal verification required'), +('AGE_DEVIATION', 'Age Deviation', 'PERSONAL', 'MEDIUM', 'Applicant age outside standard limits', 'Additional life insurance required'), +('LOW_VINTAGE', 'Low Employment Vintage', 'EMPLOYMENT', 'LOW', 'Employment tenure below minimum threshold', 'Previous employment proof required'), +('LOW_GST_FILING', 'Low GST Filing Compliance', 'GST', 'MEDIUM', 'GST filing not consistent', 'Last 12 months GST returns required'), +('FRAUD_RISK', 'Fraud Risk Detected', 'FRAUD', 'CRITICAL', 'High fraud risk score detected', 'Enhanced due diligence required'); + +-- ============================================================ +-- TRIGGERS: AUTO UPDATE TIMESTAMPS +-- ============================================================ + +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_tenants_updated_at BEFORE UPDATE ON tenants FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER trg_roles_updated_at BEFORE UPDATE ON roles FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER trg_rules_updated_at BEFORE UPDATE ON rules FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER trg_rule_sets_updated_at BEFORE UPDATE ON rule_sets FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- Auto-generate report number +CREATE OR REPLACE FUNCTION generate_report_number() +RETURNS TRIGGER AS $$ +BEGIN + NEW.report_number = 'BRE-' || TO_CHAR(NOW(), 'YYYYMMDD') || '-' || LPAD(nextval('report_seq')::TEXT, 6, '0'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE SEQUENCE IF NOT EXISTS report_seq START 1; +CREATE TRIGGER trg_decision_report_number BEFORE INSERT ON decision_reports FOR EACH ROW EXECUTE FUNCTION generate_report_number(); + +-- ============================================================ +-- VIEWS FOR ANALYTICS +-- ============================================================ + +CREATE OR REPLACE VIEW v_execution_summary AS +SELECT + er.tenant_id, + DATE_TRUNC('day', eq.created_at) AS execution_date, + COUNT(*) AS total_executions, + SUM(CASE WHEN er.final_decision = 'APPROVE' THEN 1 ELSE 0 END) AS approved, + SUM(CASE WHEN er.final_decision = 'REJECT' THEN 1 ELSE 0 END) AS rejected, + SUM(CASE WHEN er.final_decision = 'DEVIATION' THEN 1 ELSE 0 END) AS deviations, + ROUND(AVG(er.risk_score), 2) AS avg_risk_score, + ROUND(AVG(eq.processing_ms), 0) AS avg_processing_ms, + SUM(CASE WHEN er.final_decision = 'APPROVE' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS approval_rate +FROM execution_results er +JOIN execution_requests eq ON eq.id = er.request_id +GROUP BY er.tenant_id, DATE_TRUNC('day', eq.created_at); + +CREATE OR REPLACE VIEW v_rule_hit_analysis AS +SELECT + red.rule_id, + r.rule_code, + r.rule_name, + r.tenant_id, + COUNT(*) AS total_evaluations, + SUM(CASE WHEN red.is_matched THEN 1 ELSE 0 END) AS matched_count, + ROUND(SUM(CASE WHEN red.is_matched THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) AS hit_rate, + ROUND(AVG(red.execution_ms), 0) AS avg_execution_ms +FROM rule_execution_details red +JOIN rules r ON r.id = red.rule_id +GROUP BY red.rule_id, r.rule_code, r.rule_name, r.tenant_id; + +CREATE OR REPLACE VIEW v_deviation_analysis AS +SELECT + ed.tenant_id, + ed.deviation_code, + ed.deviation_name, + ed.severity, + COUNT(*) AS occurrence_count, + SUM(CASE WHEN ed.is_overridden THEN 1 ELSE 0 END) AS override_count, + DATE_TRUNC('month', ed.created_at) AS month +FROM execution_deviations ed +GROUP BY ed.tenant_id, ed.deviation_code, ed.deviation_name, ed.severity, DATE_TRUNC('month', ed.created_at); diff --git a/002_seed_admin.sql b/002_seed_admin.sql new file mode 100644 index 0000000000000000000000000000000000000000..8e9c5de935e4083f540dc9cf91954cff69210c9b --- /dev/null +++ b/002_seed_admin.sql @@ -0,0 +1,173 @@ +-- ============================================================ +-- OPTIM AI BRE ENGINE - SEED: ADMIN TENANT + USER +-- Run AFTER 001_initial_schema.sql +-- Password: Admin@1234 (BCrypt hash below) +-- ============================================================ + +-- Step 1: Create the default SYSTEM tenant +INSERT INTO tenants ( + id, tenant_code, tenant_name, display_name, + plan_type, max_rules, max_executions_per_day, + is_active, settings +) VALUES ( + 'a0000000-0000-0000-0000-000000000001', + 'SYSTEM', + 'OPTIM AI - System Tenant', + 'System Admin', + 'ENTERPRISE', + 99999, + 99999999, + TRUE, + '{"isSystem": true}'::jsonb +) ON CONFLICT (tenant_code) DO NOTHING; + +-- Step 2: Create SUPER_ADMIN role for the system tenant +INSERT INTO roles ( + id, tenant_id, role_code, role_name, description, + is_system_role, is_active +) VALUES ( + 'b0000000-0000-0000-0000-000000000001', + 'a0000000-0000-0000-0000-000000000001', + 'SUPER_ADMIN', + 'Super Administrator', + 'Full system access - all permissions', + TRUE, TRUE +) ON CONFLICT DO NOTHING; + +-- Step 3: Grant ALL permissions to SUPER_ADMIN +INSERT INTO role_permissions (role_id, permission_id) +SELECT + 'b0000000-0000-0000-0000-000000000001', + p.id +FROM permissions p +ON CONFLICT DO NOTHING; + +-- Step 4: Create the admin user +-- Email: admin@optimai.in +-- Password: Admin@1234 +-- BCrypt hash generated with work factor 12 +INSERT INTO users ( + id, tenant_id, email, username, password_hash, + full_name, designation, department, + is_active, is_email_verified +) VALUES ( + 'c0000000-0000-0000-0000-000000000001', + 'a0000000-0000-0000-0000-000000000001', + 'admin@optimai.in', + 'admin', + '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/Lewjoc.MiG6L6bHQW', + 'System Administrator', + 'Platform Administrator', + 'Technology', + TRUE, + TRUE +) ON CONFLICT (tenant_id, email) DO NOTHING; + +-- Step 5: Assign SUPER_ADMIN role to admin user +INSERT INTO user_roles (user_id, role_id) +VALUES ( + 'c0000000-0000-0000-0000-000000000001', + 'b0000000-0000-0000-0000-000000000001' +) ON CONFLICT DO NOTHING; + +-- Step 6: Create a DEMO tenant for testing multi-tenancy +INSERT INTO tenants ( + id, tenant_code, tenant_name, display_name, + plan_type, max_rules, max_executions_per_day, is_active, settings +) VALUES ( + 'a0000000-0000-0000-0000-000000000002', + 'DEMO_BANK', + 'Demo Bank Ltd', + 'Demo Bank', + 'ENTERPRISE', 500, 100000, TRUE, + '{"productTypes": ["VEHICLE_LOAN","TRACTOR_LOAN","MSME"]}'::jsonb +) ON CONFLICT (tenant_code) DO NOTHING; + +-- Step 7: Create CREDIT_MANAGER role for Demo Bank +INSERT INTO roles (id, tenant_id, role_code, role_name, is_system_role, is_active) +VALUES ( + 'b0000000-0000-0000-0000-000000000002', + 'a0000000-0000-0000-0000-000000000002', + 'CREDIT_MANAGER', 'Credit Manager', FALSE, TRUE +) ON CONFLICT DO NOTHING; + +-- Step 8: Grant rule permissions to CREDIT_MANAGER +INSERT INTO role_permissions (role_id, permission_id) +SELECT 'b0000000-0000-0000-0000-000000000002', p.id +FROM permissions p +WHERE p.permission_code IN ( + 'RULE.VIEW','RULE.CREATE','RULE.EDIT','RULE.CLONE', + 'EXECUTION.VIEW','EXECUTION.EXECUTE','EXECUTION.SANDBOX', + 'REPORT.VIEW','REPORT.EXPORT','AI.GENERATE','AI.ANALYSIS', + 'AUDIT.VIEW' +) ON CONFLICT DO NOTHING; + +-- Step 9: Create demo user for Demo Bank +-- Email: demo@demobank.in +-- Password: Demo@1234 +INSERT INTO users ( + id, tenant_id, email, username, password_hash, + full_name, designation, department, is_active, is_email_verified +) VALUES ( + 'c0000000-0000-0000-0000-000000000002', + 'a0000000-0000-0000-0000-000000000002', + 'demo@demobank.in', + 'demo_credit', + '$2a$12$9k.GJjJDiZqSVhWNi3W4C.F.5XknZ3Nuo8hHE1t3L3XuoUgJAOxFW', + 'Demo Credit Manager', + 'Credit Manager', + 'Credit Department', + TRUE, TRUE +) ON CONFLICT (tenant_id, email) DO NOTHING; + +-- Step 10: Assign CREDIT_MANAGER role to demo user +INSERT INTO user_roles (user_id, role_id) +VALUES ( + 'c0000000-0000-0000-0000-000000000002', + 'b0000000-0000-0000-0000-000000000002' +) ON CONFLICT DO NOTHING; + +-- Step 11: Seed rule categories for Demo Bank +INSERT INTO rule_categories (id, tenant_id, category_code, category_name, icon, sort_order, is_active) +VALUES + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'ELIGIBILITY', 'Eligibility Rules', '✅', 1, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'BUREAU', 'Bureau Rules', '📊', 2, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'INCOME', 'Income Rules', '💰', 3, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'VEHICLE', 'Vehicle Rules', '🚗', 4, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'FI', 'FI Rules', '🏠', 5, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'FRAUD', 'Fraud Rules', '🚨', 6, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'COMPLIANCE', 'Compliance Rules', '📋', 7, TRUE) +ON CONFLICT DO NOTHING; + +-- Step 12: Seed loan stages for Demo Bank +INSERT INTO loan_stages (id, tenant_id, stage_code, stage_name, stage_order, is_active) +VALUES + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'LOGIN', 'Login Stage', 1, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'DEDUPE', 'De-Dupe Check', 2, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'BUREAU_PULL', 'Bureau Pull', 3, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'CREDIT_EVAL', 'Credit Evaluation', 4, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'FI', 'Field Investigation', 5, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'VALUATION', 'Vehicle Valuation', 6, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'FINAL_CREDIT', 'Final Credit Decision', 7, TRUE), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'SANCTION', 'Sanction', 8, TRUE) +ON CONFLICT DO NOTHING; + +-- Step 13: Seed products for Demo Bank +INSERT INTO products (id, tenant_id, product_code, product_name, product_type, is_active, config) +VALUES + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'VL', 'Vehicle Loan', 'VEHICLE_LOAN', TRUE, '{}'::jsonb), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'TL', 'Tractor Loan', 'TRACTOR_LOAN', TRUE, '{}'::jsonb), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'AL', 'Auto Loan', 'AUTO_LOAN', TRUE, '{}'::jsonb), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'CVL', 'Commercial Vehicle Loan', 'CV_LOAN', TRUE, '{}'::jsonb), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'MSME', 'MSME Loan', 'MSME', TRUE, '{}'::jsonb), + (uuid_generate_v4(), 'a0000000-0000-0000-0000-000000000002', 'PL', 'Personal Loan', 'PERSONAL_LOAN', TRUE, '{}'::jsonb) +ON CONFLICT DO NOTHING; + +-- ============================================================ +-- VERIFICATION QUERIES (run to confirm seed data) +-- ============================================================ +-- SELECT * FROM tenants; +-- SELECT u.email, u.full_name, r.role_name FROM users u +-- JOIN user_roles ur ON ur.user_id = u.id +-- JOIN roles r ON r.id = ur.role_id; +-- SELECT COUNT(*) FROM permissions; diff --git a/01_schema.sh b/01_schema.sh new file mode 100644 index 0000000000000000000000000000000000000000..c6e1112c986f353d208dfc5e283200ea571b0dce --- /dev/null +++ b/01_schema.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# ============================================================== +# OPTIM AI BRE Engine — PostgreSQL First-Run Init Script +# Runs automatically when the postgres volume is empty. +# Applies schema + seed data in order. +# ============================================================== +set -e + +echo "================================================" +echo "OPTIM AI BRE — Initializing PostgreSQL schema..." +echo "================================================" + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + + -- Enable required extensions + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +EOSQL + +echo "Extensions created. Running migration 001..." +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ + -f /docker-entrypoint-initdb.d/migrations/001_initial_schema.sql + +echo "Running migration 002 (seed data)..." +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ + -f /docker-entrypoint-initdb.d/migrations/002_seed_admin.sql + +echo "================================================" +echo "Database initialization complete!" +echo "Admin login: admin@optimai.in / Admin@1234" +echo "Demo login: demo@demobank.in / Demo@1234" +echo "================================================" diff --git a/ActionEditor.tsx b/ActionEditor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f4c819bb7b644795064e7e8fe59fc886f440965d --- /dev/null +++ b/ActionEditor.tsx @@ -0,0 +1,208 @@ +'use client' + +import React from 'react' +import type { RuleAction, ActionType, Decision, RiskCategory, TrafficLight, Severity } from '@/types' + +interface Props { + action: RuleAction + index: number + onChange: (action: RuleAction) => void + onDelete: () => void + readOnly?: boolean +} + +const ACTION_CONFIGS: Record = { + SET_DECISION: { + label: 'Set Decision', + icon: '⚖️', + color: 'bg-red-50 border-red-200', + valueType: 'decision', + description: 'Sets the final loan decision', + }, + SET_RISK: { + label: 'Set Risk Level', + icon: '⚠️', + color: 'bg-yellow-50 border-yellow-200', + valueType: 'risk', + description: 'Sets the risk category', + }, + SET_TRAFFIC_LIGHT: { + label: 'Set Traffic Light', + icon: '🚦', + color: 'bg-green-50 border-green-200', + valueType: 'traffic_light', + description: 'Sets the visual status indicator', + }, + ADD_DEVIATION: { + label: 'Add Deviation', + icon: '📋', + color: 'bg-orange-50 border-orange-200', + valueType: 'deviation', + description: 'Flags a policy deviation', + }, + SET_FIELD: { + label: 'Set Output Field', + icon: '📝', + color: 'bg-blue-50 border-blue-200', + valueType: 'text', + description: 'Sets a custom output field value', + }, + ADD_TAG: { + label: 'Add Tag', + icon: '🏷️', + color: 'bg-purple-50 border-purple-200', + valueType: 'text', + description: 'Adds a tag to the decision', + }, + SET_SCORE: { + label: 'Set Risk Score', + icon: '📊', + color: 'bg-teal-50 border-teal-200', + valueType: 'score', + description: 'Sets or adjusts the risk score (0-100)', + }, +} + +export function ActionEditor({ action, index, onChange, onDelete, readOnly }: Props) { + const config = ACTION_CONFIGS[action.type] + + return ( +
+ {/* Action Number */} +
+ {index + 1} +
+ + {/* Icon */} + {config.icon} + + {/* Type Selector */} + + + {/* Value Input */} + {config.valueType === 'decision' && ( + + )} + + {config.valueType === 'risk' && ( + + )} + + {config.valueType === 'traffic_light' && ( + + )} + + {config.valueType === 'deviation' && ( +
+ onChange({ ...action, value: e.target.value })} + placeholder="Deviation code (e.g., LOW_BUREAU_SCORE)" + 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" + /> + onChange({ ...action, parameters: { ...action.parameters, severity: e.target.value } })} + placeholder="Severity" + className="w-24 text-sm border border-white/70 bg-white rounded-lg px-2 py-1 focus:outline-none focus:border-blue-400" + /> +
+ )} + + {config.valueType === 'text' && ( +
+ {action.type === 'SET_FIELD' && ( + onChange({ ...action, field: e.target.value })} + placeholder="Field name" + 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" + /> + )} + onChange({ ...action, value: e.target.value })} + placeholder={action.type === 'ADD_TAG' ? 'tag-name' : 'Value'} + className="flex-1 text-sm border border-white/70 bg-white rounded-lg px-3 py-1 focus:outline-none focus:border-blue-400" + /> +
+ )} + + {config.valueType === 'score' && ( +
+ onChange({ ...action, value: e.target.value })} + placeholder="0–100 or +/-offset" + className="w-40 text-sm border border-white/70 bg-white rounded-lg px-3 py-1 focus:outline-none focus:border-blue-400" + /> + Prefix +/- for adjustment +
+ )} + + {/* Description */} + {config.description} + + {/* Delete */} + {!readOnly && ( + + )} +
+ ) +} diff --git a/ActionExecutor.cs b/ActionExecutor.cs new file mode 100644 index 0000000000000000000000000000000000000000..9becde4b8ca13355c3b18270789b5510a5688aaa --- /dev/null +++ b/ActionExecutor.cs @@ -0,0 +1,177 @@ +using Microsoft.Extensions.Logging; +using OptimAI.BRE.RuleEngine.Domain; +using OptimAI.BRE.Shared.Domain; + +namespace OptimAI.BRE.RuleEngine.Application; + +public sealed class ActionExecutor : IActionExecutor +{ + private readonly IDeviationTypeRepository _deviationTypeRepo; + private readonly ILogger _logger; + + public ActionExecutor(IDeviationTypeRepository deviationTypeRepo, ILogger logger) + { + _deviationTypeRepo = deviationTypeRepo; + _logger = logger; + } + + public async Task ExecuteAsync(RuleAction action, ExecutionContext context, RuleExecutionState state) + { + try + { + switch (action.Type) + { + case ActionType.SetDecision: + ExecuteSetDecision(action, state); + break; + + case ActionType.SetRisk: + ExecuteSetRisk(action, state); + break; + + case ActionType.SetTrafficLight: + ExecuteSetTrafficLight(action, state); + break; + + case ActionType.AddDeviation: + await ExecuteAddDeviationAsync(action, context, state); + break; + + case ActionType.SetField: + ExecuteSetField(action, state); + break; + + case ActionType.AddTag: + ExecuteAddTag(action, state); + break; + + case ActionType.SetScore: + ExecuteSetScore(action, state); + break; + + default: + _logger.LogWarning("Unknown action type: {ActionType}", action.Type); + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Action execution failed: {ActionType}", action.Type); + } + } + + private static void ExecuteSetDecision(RuleAction action, RuleExecutionState state) + { + if (Enum.TryParse(action.Value, true, out var decision)) + { + // Higher severity decisions take priority: Reject > Deviation > Refer > Approve + var priority = new Dictionary + { + [Decision.Reject] = 4, + [Decision.Deviation] = 3, + [Decision.Refer] = 2, + [Decision.Approve] = 1, + [Decision.Pending] = 0 + }; + + if (priority.GetValueOrDefault(decision, 0) > priority.GetValueOrDefault(state.GlobalState.CurrentDecision, 0)) + { + state.GlobalState.CurrentDecision = decision; + + if (decision == Decision.Reject) + state.GlobalState.ShouldStop = false; // Continue collecting all rejection reasons + } + } + } + + private static void ExecuteSetRisk(RuleAction action, RuleExecutionState state) + { + if (Enum.TryParse(action.Value, true, out var risk)) + { + // Escalate risk level only + var currentRisk = state.GlobalState.OutputFields.GetValueOrDefault("_riskCategory") as string; + if (currentRisk == null || ShouldEscalate(currentRisk, risk.ToString())) + state.GlobalState.OutputFields["_riskCategory"] = risk.ToString(); + } + } + + private static void ExecuteSetTrafficLight(RuleAction action, RuleExecutionState state) + { + if (Enum.TryParse(action.Value, true, out var tl)) + state.GlobalState.TrafficLight = tl; + } + + private async Task ExecuteAddDeviationAsync(RuleAction action, ExecutionContext context, RuleExecutionState state) + { + var deviationCode = action.Value ?? action.Parameters.GetValueOrDefault("code"); + if (deviationCode == null) return; + + var deviationType = await _deviationTypeRepo.FindByCodeAsync(context.TenantId, deviationCode); + + var deviation = new ExecutionDeviation + { + TenantId = context.TenantId, + DeviationCode = deviationCode, + DeviationName = deviationType?.DeviationName ?? deviationCode, + Severity = deviationType?.DefaultSeverity ?? ParseSeverity(action.Parameters.GetValueOrDefault("severity")), + Reason = action.Parameters.GetValueOrDefault("reason") ?? deviationType?.Description ?? "Policy deviation detected", + FieldPath = action.Parameters.GetValueOrDefault("field"), + RecommendedAction = action.Parameters.GetValueOrDefault("action") ?? deviationType?.RecommendedAction, + DeviationTypeId = deviationType?.Id + }; + + // Resolve field values for context + if (deviation.FieldPath != null) + { + var val = context.Data.GetValue(deviation.FieldPath); + deviation.ActualValue = val?.ToString(); + } + + state.GlobalState.Deviations.Add(deviation); + + // Automatically set decision to DEVIATION if not already REJECT + if (state.GlobalState.CurrentDecision != Decision.Reject) + state.GlobalState.CurrentDecision = Decision.Deviation; + } + + private static void ExecuteSetField(RuleAction action, RuleExecutionState state) + { + if (action.Field != null) + state.GlobalState.OutputFields[action.Field] = action.Value ?? ""; + } + + private static void ExecuteAddTag(RuleAction action, RuleExecutionState state) + { + if (action.Value != null && !state.GlobalState.Tags.Contains(action.Value)) + state.GlobalState.Tags.Add(action.Value); + } + + private static void ExecuteSetScore(RuleAction action, RuleExecutionState state) + { + if (decimal.TryParse(action.Value, out var score)) + { + // Score adjustments: can be absolute or relative (+10, -5) + if (action.Value!.StartsWith('+') || action.Value.StartsWith('-')) + state.GlobalState.RiskScore = Math.Clamp(state.GlobalState.RiskScore + score, 0, 100); + else + state.GlobalState.RiskScore = Math.Clamp(score, 0, 100); + } + } + + private static bool ShouldEscalate(string current, string incoming) + { + var order = new[] { "Low", "Medium", "High", "Critical" }; + var currentIdx = Array.IndexOf(order, current); + var incomingIdx = Array.IndexOf(order, incoming); + return incomingIdx > currentIdx; + } + + private static Severity ParseSeverity(string? value) => + Enum.TryParse(value, true, out var s) ? s : Severity.Medium; +} + +public interface IDeviationTypeRepository +{ + Task FindByCodeAsync(Guid tenantId, string code, CancellationToken ct = default); + Task> GetAllActiveAsync(Guid tenantId, CancellationToken ct = default); +} diff --git a/AiAnalystPanel.tsx b/AiAnalystPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..80457ad502eb2dda2f3eedb908d1ff99425eee09 --- /dev/null +++ b/AiAnalystPanel.tsx @@ -0,0 +1,244 @@ +'use client' + +import React, { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import type { AiAnalysis, BREDecisionResponse, Deviation } from '@/types' +import { apiClient } from '@/lib/api-client' + +interface Props { + decision: BREDecisionResponse +} + +export function AiAnalystPanel({ decision }: Props) { + const [aiResult, setAiResult] = useState(decision.aiAnalysis ?? null) + const [activeSection, setActiveSection] = useState('summary') + + const analyze = useMutation({ + mutationFn: () => + apiClient.post('/ai/analyze-credit', { + applicationData: {}, + decision: decision.decision, + riskScore: decision.riskScore, + riskCategory: decision.riskCategory, + deviations: [], + }) as Promise, + onSuccess: setAiResult, + }) + + const sections = [ + { id: 'summary', label: 'Risk Summary', icon: '📊' }, + { id: 'credit', label: 'Credit Analysis', icon: '💳' }, + { id: 'strengths', label: 'Strengths', icon: '💪' }, + { id: 'weaknesses', label: 'Weaknesses', icon: '⚠️' }, + { id: 'deviations', label: 'Deviations', icon: '📋' }, + { id: 'docs', label: 'Documents Required', icon: '📂' }, + { id: 'notes', label: 'Underwriting Notes', icon: '📝' }, + ] + + return ( +
+ {/* Header */} +
+
+
+
🤖
+
+

OPTIM AI Credit Analyst

+

AI-powered credit risk assessment

+
+
+ {!aiResult && ( + + )} + {aiResult && ( +
+
Confidence:
+
{Math.round(aiResult.confidenceScore * 100)}%
+
+ )} +
+
+ + {/* Decision Summary Bar */} +
+ +
+
+ Decision: + {decision.decision} +
+
+ Risk Score: + {decision.riskScore.toFixed(1)}/100 +
+
+ Risk: + +
+
+ Rules: + + {decision.rulesPassed}/{decision.totalRulesEvaluated} matched + +
+
+ Deviations: + {decision.deviationsCount} +
+
+ Time: + {decision.executionMs}ms +
+
+
+ + {aiResult ? ( +
+ {/* Sidebar Nav */} +
+ {sections.map((s) => ( + + ))} +
+ + {/* Content */} +
+ {activeSection === 'summary' && ( + + )} + {activeSection === 'credit' && ( + + )} + {activeSection === 'strengths' && ( + + )} + {activeSection === 'weaknesses' && ( + + )} + {activeSection === 'deviations' && ( +
+ + {aiResult.rejectionReasons.length > 0 && ( +
+ +
+ )} +
+ )} + {activeSection === 'docs' && ( + + )} + {activeSection === 'notes' && ( + + )} +
+
+ ) : ( +
+
🤖
+

AI Analysis Not Yet Run

+

Click "Run AI Analysis" to get comprehensive credit insights

+
+ )} +
+ ) +} + +function AiSection({ title, icon, content }: { title: string; icon: string; content: string }) { + return ( +
+

+ {icon} {title} +

+
+ {content || No content available} +
+
+ ) +} + +function ListSection({ + title, icon, items, variant +}: { + title: string; icon: string; items: string[]; variant: 'success' | 'warning' | 'error' | 'info' +}) { + const styles = { + success: { dot: 'bg-emerald-500', bg: 'bg-emerald-50', border: 'border-emerald-100' }, + warning: { dot: 'bg-amber-500', bg: 'bg-amber-50', border: 'border-amber-100' }, + error: { dot: 'bg-red-500', bg: 'bg-red-50', border: 'border-red-100' }, + info: { dot: 'bg-blue-500', bg: 'bg-blue-50', border: 'border-blue-100' }, + }[variant] + + return ( +
+

+ {icon} {title} +

+ {items.length === 0 ? ( +

None identified

+ ) : ( +
+ {items.map((item, idx) => ( +
+
+ {item} +
+ ))} +
+ )} +
+ ) +} + +function TrafficLightIndicator({ light }: { light: string }) { + return ( +
+ {(['GREEN', 'AMBER', 'RED'] as const).map((l) => ( +
+ ))} +
+ ) +} + +function RiskBadge({ category }: { category: string }) { + const colors: Record = { + LOW: 'ml-2 px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded text-xs font-semibold', + MEDIUM: 'ml-2 px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs font-semibold', + HIGH: 'ml-2 px-2 py-0.5 bg-orange-100 text-orange-700 rounded text-xs font-semibold', + CRITICAL: 'ml-2 px-2 py-0.5 bg-red-100 text-red-700 rounded text-xs font-semibold', + } + return {category} +} diff --git a/AiController.cs b/AiController.cs new file mode 100644 index 0000000000000000000000000000000000000000..cb007d92ebef10dbd3639370d3eab50eb390f998 --- /dev/null +++ b/AiController.cs @@ -0,0 +1,231 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OptimAI.BRE.AIEngine.Application; +using OptimAI.BRE.RuleEngine.Api; +using OptimAI.BRE.Shared.Domain; + +namespace OptimAI.BRE.AIEngine.Api; + +[ApiController] +[Route("api/v1/ai")] +[Authorize] +public sealed class AiController : ControllerBase +{ + private readonly IAiCreditAnalystService _aiService; + private readonly IAiRuleGeneratorRepository _generatedRuleRepo; + private readonly ITenantContextAccessor _tenantAccessor; + private readonly IFieldCatalogRepository _fieldCatalogRepo; + private readonly ILogger _logger; + + public AiController( + IAiCreditAnalystService aiService, + IAiRuleGeneratorRepository generatedRuleRepo, + ITenantContextAccessor tenantAccessor, + IFieldCatalogRepository fieldCatalogRepo, + ILogger logger) + { + _aiService = aiService; + _generatedRuleRepo = generatedRuleRepo; + _tenantAccessor = tenantAccessor; + _fieldCatalogRepo = fieldCatalogRepo; + _logger = logger; + } + + /// + /// Generate BRE rule from natural language description using AI. + /// + [HttpPost("generate-rule")] + [Authorize(Policy = "AiGenerate")] + [ProducesResponseType(typeof(GeneratedRuleResponse), 200)] + public async Task GenerateRule([FromBody] GenerateRuleRequest request, CancellationToken ct) + { + var availableFields = await _fieldCatalogRepo.GetFieldPathsAsync(_tenantAccessor.TenantId, ct); + + var ruleDefinition = await _aiService.GenerateRuleFromPromptAsync(new RuleGenerationRequest + { + UserPrompt = request.Prompt, + ProductType = request.ProductType, + AvailableFields = availableFields, + TenantId = _tenantAccessor.TenantId, + CreatedBy = _tenantAccessor.UserId + }, ct); + + if (ruleDefinition == null) + return BadRequest(new { error = "AI could not generate a rule from the provided description. Please be more specific." }); + + var savedId = await _generatedRuleRepo.SaveGeneratedRuleAsync(new AiGeneratedRuleRecord + { + TenantId = _tenantAccessor.TenantId, + UserPrompt = request.Prompt, + GeneratedRule = ruleDefinition, + CreatedBy = _tenantAccessor.UserId + }, ct); + + return Ok(new GeneratedRuleResponse + { + GenerationId = savedId, + UserPrompt = request.Prompt, + RuleDefinition = ruleDefinition, + CanEdit = true, + Message = "Rule generated successfully. Review and save to rule engine." + }); + } + + /// + /// Run AI credit analysis on execution result. + /// + [HttpPost("analyze-credit")] + [Authorize(Policy = "AiAnalysis")] + [ProducesResponseType(typeof(AiAnalysis), 200)] + public async Task AnalyzeCredit([FromBody] CreditAnalysisApiRequest request, CancellationToken ct) + { + var analysis = await _aiService.AnalyzeCreditAsync(new CreditAnalysisRequest + { + Data = request.ApplicationData, + Decision = request.Decision, + RiskScore = request.RiskScore, + RiskCategory = request.RiskCategory, + ProductCode = request.ProductCode, + Deviations = request.Deviations.Select(d => new ExecutionDeviation + { + DeviationCode = d.Code, + DeviationName = d.Name, + Severity = Enum.Parse(d.Severity, true), + Reason = d.Reason + }).ToList() + }, ct); + + return Ok(analysis); + } + + /// + /// Analyze deviations and provide mitigation recommendations. + /// + [HttpPost("analyze-deviations")] + [Authorize(Policy = "AiAnalysis")] + [ProducesResponseType(typeof(DeviationAnalysis), 200)] + public async Task AnalyzeDeviations([FromBody] DeviationAnalysisApiRequest request, CancellationToken ct) + { + var analysis = await _aiService.AnalyzeDeviationsAsync(new DeviationAnalysisRequest + { + ApplicationData = request.ApplicationData, + Deviations = request.Deviations.Select(d => new ExecutionDeviation + { + DeviationCode = d.Code, + DeviationName = d.Name, + Severity = Enum.Parse(d.Severity, true), + Reason = d.Reason, + ActualValue = d.ActualValue, + ExpectedValue = d.ExpectedValue, + TenantId = _tenantAccessor.TenantId + }).ToList() + }, ct); + + return Ok(analysis); + } + + /// + /// Accept a generated rule and save to rule engine. + /// + [HttpPost("generated-rules/{generationId:guid}/accept")] + [Authorize(Policy = "RuleWrite")] + public async Task AcceptGeneratedRule(Guid generationId, [FromBody] AcceptRuleRequest request, CancellationToken ct) + { + var saved = await _generatedRuleRepo.AcceptRuleAsync(generationId, _tenantAccessor.TenantId, request.CategoryId, _tenantAccessor.UserId, ct); + if (!saved) return NotFound(); + + return Ok(new { message = "Rule saved to rule engine successfully", ruleId = saved }); + } + + /// + /// Get AI generation history. + /// + [HttpGet("generated-rules")] + [ProducesResponseType(typeof(List), 200)] + public async Task GetGenerationHistory([FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) + { + var history = await _generatedRuleRepo.GetHistoryAsync(_tenantAccessor.TenantId, page, pageSize, ct); + return Ok(history); + } +} + +// DTOs +public record GenerateRuleRequest +{ + public string Prompt { get; init; } = default!; + public string? ProductType { get; init; } +} + +public record GeneratedRuleResponse +{ + public Guid GenerationId { get; init; } + public string UserPrompt { get; init; } = default!; + public RuleDefinition RuleDefinition { get; init; } = default!; + public bool CanEdit { get; init; } + public string Message { get; init; } = default!; +} + +public record CreditAnalysisApiRequest +{ + public Dictionary ApplicationData { get; init; } = new(); + public string Decision { get; init; } = default!; + public decimal RiskScore { get; init; } + public string RiskCategory { get; init; } = default!; + public string? ProductCode { get; init; } + public List Deviations { get; init; } = new(); +} + +public record DeviationAnalysisApiRequest +{ + public Dictionary ApplicationData { get; init; } = new(); + public List Deviations { get; init; } = new(); +} + +public record DeviationDto +{ + public string Code { get; init; } = default!; + public string Name { get; init; } = default!; + public string Severity { get; init; } = default!; + public string Reason { get; init; } = default!; + public string? ActualValue { get; init; } + public string? ExpectedValue { get; init; } +} + +public record AcceptRuleRequest +{ + public Guid? CategoryId { get; init; } +} + +public class AiGeneratedRuleRecord +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public string UserPrompt { get; set; } = default!; + public RuleDefinition GeneratedRule { get; set; } = default!; + public Guid? RuleId { get; set; } + public bool? IsAccepted { get; set; } + public Guid CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public interface IAiRuleGeneratorRepository +{ + Task SaveGeneratedRuleAsync(AiGeneratedRuleRecord record, CancellationToken ct = default); + Task AcceptRuleAsync(Guid generationId, Guid tenantId, Guid? categoryId, Guid userId, CancellationToken ct = default); + Task> GetHistoryAsync(Guid tenantId, int page, int pageSize, CancellationToken ct = default); +} + +public interface IFieldCatalogRepository +{ + Task> GetFieldPathsAsync(Guid tenantId, CancellationToken ct = default); + Task> GetCatalogAsync(Guid tenantId, string? category = null, CancellationToken ct = default); +} + +public record FieldCatalogDto +{ + public string FieldPath { get; init; } = default!; + public string DisplayName { get; init; } = default!; + public string DataType { get; init; } = default!; + public string? Category { get; init; } + public string? Description { get; init; } +} diff --git a/AppShell.tsx b/AppShell.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4c12f9ddb7314223a7cd4dad081cf304198148e6 --- /dev/null +++ b/AppShell.tsx @@ -0,0 +1,90 @@ +'use client' + +import React, { useState } from 'react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +const NAV_ITEMS = [ + { href: '/', label: 'Dashboard', icon: '📊' }, + { href: '/rules', label: 'Rule Designer', icon: '⚙️' }, + { href: '/rule-sets', label: 'Rule Sets', icon: '📦' }, + { href: '/sandbox', label: 'Sandbox', icon: '🧪' }, + { href: '/decisions', label: 'Decisions', icon: '⚖️' }, + { href: '/deviations', label: 'Deviations', icon: '⚠️' }, + { href: '/ai-rules', label: 'AI Rule Creator', icon: '🤖' }, + { href: '/marketplace', label: 'Marketplace', icon: '🛒' }, + { href: '/clients', label: 'Clients', icon: '🏢' }, + { href: '/reports', label: 'Reports', icon: '📄' }, + { href: '/audit', label: 'Audit Trail', icon: '🔍' }, + { href: '/settings', label: 'Settings', icon: '🔧' }, +] + +export function AppShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname() + const [collapsed, setCollapsed] = useState(false) + + // Don't show shell on login page + if (pathname === '/login') return <>{children} + + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
{children}
+
+ ) +} diff --git a/AuthController.cs b/AuthController.cs new file mode 100644 index 0000000000000000000000000000000000000000..d64557690beb93d7ca9ebaa5488a885ae3e304be --- /dev/null +++ b/AuthController.cs @@ -0,0 +1,234 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using OptimAI.BRE.RuleEngine.Infrastructure; +using OptimAI.BRE.Shared.Domain; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace OptimAI.BRE.IdentityService.Api; + +[ApiController] +[Route("api/v1/auth")] +public sealed class AuthController : ControllerBase +{ + private readonly BREDbContext _db; + private readonly JwtOptions _jwt; + private readonly ILogger _logger; + + public AuthController(BREDbContext db, IOptions jwt, ILogger logger) + { + _db = db; + _jwt = jwt.Value; + _logger = logger; + } + + [HttpPost("login")] + [ProducesResponseType(typeof(LoginResponse), 200)] + [ProducesResponseType(typeof(ProblemDetails), 401)] + public async Task Login([FromBody] LoginRequest request, CancellationToken ct) + { + var user = await _db.Users + .Include(u => u.UserRoles) + .ThenInclude(ur => ur.Role) + .ThenInclude(r => r.RolePermissions) + .ThenInclude(rp => rp.Permission) + .FirstOrDefaultAsync(u => + u.Email == request.Email.ToLower() && + u.IsActive, ct); + + if (user == null || !VerifyPassword(request.Password, user.PasswordHash)) + { + if (user != null) + { + user.FailedLoginCount++; + if (user.FailedLoginCount >= 5) + user.LockedUntil = DateTime.UtcNow.AddMinutes(30); + await _db.SaveChangesAsync(ct); + } + return Unauthorized(new { error = "Invalid email or password" }); + } + + if (user.LockedUntil.HasValue && user.LockedUntil > DateTime.UtcNow) + return Unauthorized(new { error = "Account locked. Try again later." }); + + user.FailedLoginCount = 0; + user.LastLoginAt = DateTime.UtcNow; + user.LastLoginIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + await _db.SaveChangesAsync(ct); + + var permissions = user.UserRoles + .SelectMany(ur => ur.Role.RolePermissions) + .Select(rp => rp.Permission.PermissionCode) + .Distinct() + .ToList(); + + var accessToken = GenerateAccessToken(user, permissions); + var refreshToken = GenerateRefreshToken(); + + _db.RefreshTokens.Add(new RefreshToken + { + UserId = user.Id, + TokenHash = HashToken(refreshToken), + ExpiresAt = DateTime.UtcNow.AddDays(_jwt.RefreshTokenExpiryDays), + IpAddress = user.LastLoginIp, + UserAgent = Request.Headers.UserAgent.ToString() + }); + await _db.SaveChangesAsync(ct); + + return Ok(new LoginResponse + { + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiresIn = _jwt.AccessTokenExpiryMinutes * 60, + TokenType = "Bearer", + User = new UserDto + { + Id = user.Id, + Email = user.Email, + FullName = user.FullName, + TenantId = user.TenantId, + Permissions = permissions, + Roles = user.UserRoles.Select(ur => ur.Role.RoleCode).ToList() + } + }); + } + + [HttpPost("refresh")] + public async Task Refresh([FromBody] RefreshRequest request, CancellationToken ct) + { + var hash = HashToken(request.RefreshToken); + var token = await _db.RefreshTokens + .Include(t => t.User) + .FirstOrDefaultAsync(t => + t.TokenHash == hash && + !t.IsRevoked && + t.ExpiresAt > DateTime.UtcNow, ct); + + if (token == null) + return Unauthorized(new { error = "Invalid or expired refresh token" }); + + // Rotate refresh token + token.IsRevoked = true; + var newRefreshToken = GenerateRefreshToken(); + _db.RefreshTokens.Add(new RefreshToken + { + UserId = token.UserId, + TokenHash = HashToken(newRefreshToken), + ExpiresAt = DateTime.UtcNow.AddDays(_jwt.RefreshTokenExpiryDays) + }); + + await _db.SaveChangesAsync(ct); + + var permissions = await GetUserPermissionsAsync(token.UserId, ct); + var accessToken = GenerateAccessToken(token.User!, permissions); + + return Ok(new { accessToken, refreshToken = newRefreshToken }); + } + + [HttpPost("logout")] + public async Task Logout([FromBody] LogoutRequest request, CancellationToken ct) + { + var hash = HashToken(request.RefreshToken); + await _db.RefreshTokens + .Where(t => t.TokenHash == hash) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.IsRevoked, true), ct); + return Ok(new { message = "Logged out successfully" }); + } + + private string GenerateAccessToken(User user, List permissions) + { + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(JwtRegisteredClaimNames.Email, user.Email), + new(JwtRegisteredClaimNames.Name, user.FullName), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new("tenant_id", user.TenantId.ToString()), + }; + + foreach (var p in permissions) + claims.Add(new Claim("permission", p)); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SecretKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _jwt.Issuer, + audience: _jwt.Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_jwt.AccessTokenExpiryMinutes), + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static string GenerateRefreshToken() + { + var bytes = new byte[64]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return Convert.ToBase64String(bytes); + } + + private static string HashToken(string token) + { + using var sha = SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(token))).ToLower(); + } + + private static bool VerifyPassword(string password, string hash) + => BCrypt.Net.BCrypt.Verify(password, hash); + + private async Task> GetUserPermissionsAsync(Guid userId, CancellationToken ct) + { + return await _db.UserRoles + .Where(ur => ur.UserId == userId) + .SelectMany(ur => ur.Role.RolePermissions) + .Select(rp => rp.Permission.PermissionCode) + .Distinct() + .ToListAsync(ct); + } +} + +// Models +public class JwtOptions +{ + public string SecretKey { get; set; } = default!; + public string Issuer { get; set; } = default!; + public string Audience { get; set; } = default!; + public int AccessTokenExpiryMinutes { get; set; } = 60; + public int RefreshTokenExpiryDays { get; set; } = 30; +} + +public record LoginRequest +{ + public string Email { get; init; } = default!; + public string Password { get; init; } = default!; + public bool RememberMe { get; init; } +} + +public record LoginResponse +{ + public string AccessToken { get; init; } = default!; + public string RefreshToken { get; init; } = default!; + public int ExpiresIn { get; init; } + public string TokenType { get; init; } = "Bearer"; + public UserDto User { get; init; } = default!; +} + +public record UserDto +{ + public Guid Id { get; init; } + public string Email { get; init; } = default!; + public string FullName { get; init; } = default!; + public Guid TenantId { get; init; } + public List Permissions { get; init; } = new(); + public List Roles { get; init; } = new(); +} + +public record RefreshRequest { public string RefreshToken { get; init; } = default!; } +public record LogoutRequest { public string RefreshToken { get; init; } = default!; } diff --git a/BREController.cs b/BREController.cs new file mode 100644 index 0000000000000000000000000000000000000000..892031f981998516954fac4cdf3cb69aa0413fca --- /dev/null +++ b/BREController.cs @@ -0,0 +1,429 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OptimAI.BRE.RuleEngine.Application; +using OptimAI.BRE.RuleEngine.Domain; +using OptimAI.BRE.Shared.Domain; +using System.ComponentModel.DataAnnotations; + +namespace OptimAI.BRE.RuleEngine.Api; + +[ApiController] +[Route("api/v1")] +[Authorize] +public sealed class BREController : ControllerBase +{ + private readonly IRuleExecutionService _executionService; + private readonly IExecutionRequestRepository _requestRepo; + private readonly IExecutionResultRepository _resultRepo; + private readonly ISandboxService _sandboxService; + private readonly IDecisionReportService _reportService; + private readonly ITenantContextAccessor _tenantAccessor; + private readonly ILogger _logger; + + public BREController( + IRuleExecutionService executionService, + IExecutionRequestRepository requestRepo, + IExecutionResultRepository resultRepo, + ISandboxService sandboxService, + IDecisionReportService reportService, + ITenantContextAccessor tenantAccessor, + ILogger logger) + { + _executionService = executionService; + _requestRepo = requestRepo; + _resultRepo = resultRepo; + _sandboxService = sandboxService; + _reportService = reportService; + _tenantAccessor = tenantAccessor; + _logger = logger; + } + + /// + /// Execute BRE rules against provided JSON payload. Core decision API. + /// + [HttpPost("execute-bre")] + [EnableRateLimiting("bre-execution")] + [ProducesResponseType(typeof(BREDecisionResponse), 200)] + [ProducesResponseType(typeof(ProblemDetails), 400)] + [ProducesResponseType(typeof(ProblemDetails), 429)] + public async Task ExecuteBre( + [FromBody] BREExecutionRequest request, + CancellationToken ct) + { + var tenantId = _tenantAccessor.TenantId; + + var executionRequest = new ExecutionRequest + { + TenantId = tenantId, + CorrelationId = request.CorrelationId ?? Guid.NewGuid().ToString(), + ApplicationId = request.ApplicationId, + ProductCode = request.ProductCode, + BranchCode = request.BranchCode, + StageCode = request.StageCode, + RuleSetId = request.RuleSetId, + InputPayload = request.Data, + SourceSystem = request.SourceSystem, + Status = ExecutionStatus.Processing, + ProcessingStartedAt = DateTime.UtcNow + }; + + executionRequest = await _requestRepo.CreateAsync(executionRequest, ct); + + var context = new ExecutionContext + { + TenantId = tenantId, + CorrelationId = executionRequest.CorrelationId, + ApplicationId = request.ApplicationId, + ProductCode = request.ProductCode, + BranchCode = request.BranchCode, + StageCode = request.StageCode, + RuleSetId = request.RuleSetId, + Data = new DynamicDataContext(request.Data), + Options = new ExecutionOptions + { + EnableAiAnalysis = request.EnableAiAnalysis ?? true, + EnableRiskScoring = true, + StopOnFirstReject = false, + TimeoutMs = 5000 + } + }; + + var result = await _executionService.ExecuteAsync(context, ct); + result.RequestId = executionRequest.Id; + + await _resultRepo.SaveAsync(result, ct); + await _requestRepo.MarkCompletedAsync(executionRequest.Id, ct); + + var report = await _reportService.GenerateAsync(executionRequest.Id, result, ct); + + return Ok(new BREDecisionResponse + { + RequestId = executionRequest.Id, + CorrelationId = executionRequest.CorrelationId!, + ApplicationId = request.ApplicationId, + Decision = result.FinalDecision.ToString(), + TrafficLight = result.TrafficLight?.ToString() ?? "AMBER", + RiskScore = result.RiskScore ?? 0, + RiskCategory = result.RiskCategory?.ToString() ?? "MEDIUM", + TotalRulesEvaluated = result.TotalRulesEvaluated, + RulesPassed = result.RulesPassed, + RulesFailed = result.RulesFailed, + DeviationsCount = result.DeviationsCount, + ExecutionMs = result.ExecutionMs ?? 0, + RuleResults = result.RuleResults.Select(r => new RuleResultDto + { + RuleCode = r.RuleCode, + RuleName = r.RuleName, + IsMatched = r.IsMatched, + ActionsExecuted = r.ActionsExecuted, + ExecutionMs = r.ExecutionMs ?? 0 + }).ToList(), + AiSummary = result.AiSummary, + AiAnalysis = result.AiAnalysis != null ? new AiAnalysisDto + { + RiskSummary = result.AiAnalysis.RiskSummary, + CreditSummary = result.AiAnalysis.CreditSummary, + Strengths = result.AiAnalysis.Strengths, + Weaknesses = result.AiAnalysis.Weaknesses, + ApprovalRecommendation = result.AiAnalysis.ApprovalRecommendation, + RejectionReasons = result.AiAnalysis.RejectionReasons, + AdditionalDocuments = result.AiAnalysis.AdditionalDocuments, + UnderwritingNotes = result.AiAnalysis.UnderwritingNotes, + ConfidenceScore = result.AiAnalysis.ConfidenceScore + } : null, + ReportId = report?.Id, + GeneratedAt = DateTime.UtcNow + }); + } + + /// + /// Validate a rule definition before saving. + /// + [HttpPost("validate-rule")] + [Authorize(Policy = "RuleWrite")] + [ProducesResponseType(typeof(RuleValidationResponse), 200)] + public async Task ValidateRule([FromBody] RuleValidationRequest request) + { + var errors = new List(); + var warnings = new List(); + + // Validate rule definition structure + if (request.RuleDefinition == null) + { + errors.Add("Rule definition is required"); + return Ok(new RuleValidationResponse { IsValid = false, Errors = errors }); + } + + if (request.RuleDefinition.Conditions == null || !request.RuleDefinition.Conditions.Rules.Any()) + errors.Add("At least one condition is required"); + + if (!request.RuleDefinition.Actions.Any()) + warnings.Add("Rule has no actions defined"); + + // Check referenced fields exist in catalog + var allFields = ExtractFieldPaths(request.RuleDefinition.Conditions); + foreach (var field in allFields) + { + if (!IsValidFieldPath(field)) + warnings.Add($"Field '{field}' not found in field catalog"); + } + + // Test with sample data if provided + if (request.SampleData != null) + { + var context = new ExecutionContext + { + TenantId = _tenantAccessor.TenantId, + Data = new DynamicDataContext(request.SampleData), + Options = new ExecutionOptions { EnableAiAnalysis = false } + }; + + // Quick dry-run against sample data using the condition evaluator + // This validates conditions can be parsed and executed + } + + return Ok(new RuleValidationResponse + { + IsValid = !errors.Any(), + Errors = errors, + Warnings = warnings, + ExtractedFields = allFields + }); + } + + /// + /// Simulate BRE execution without persisting results. + /// + [HttpPost("simulate-decision")] + [Authorize(Policy = "SandboxAccess")] + [ProducesResponseType(typeof(SimulationResponse), 200)] + public async Task SimulateDecision([FromBody] SimulationRequest request, CancellationToken ct) + { + var result = await _sandboxService.SimulateAsync(new SandboxRequest + { + TenantId = _tenantAccessor.TenantId, + RuleSetId = request.RuleSetId, + TestData = request.Data, + RuleIds = request.RuleIds, + CreatedBy = _tenantAccessor.UserId + }, ct); + + return Ok(result); + } + + /// + /// Get execution result by request ID. + /// + [HttpGet("decisions/{requestId:guid}")] + [ProducesResponseType(typeof(BREDecisionResponse), 200)] + [ProducesResponseType(404)] + public async Task GetDecision(Guid requestId, CancellationToken ct) + { + var result = await _resultRepo.GetByRequestIdAsync(requestId, _tenantAccessor.TenantId, ct); + if (result == null) return NotFound(); + return Ok(result); + } + + /// + /// Get decision history with pagination. + /// + [HttpGet("decisions")] + [ProducesResponseType(typeof(PagedResponse), 200)] + public async Task GetDecisionHistory( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? decision = null, + [FromQuery] string? applicationId = null, + [FromQuery] DateTime? fromDate = null, + [FromQuery] DateTime? toDate = null, + CancellationToken ct = default) + { + var result = await _resultRepo.GetHistoryAsync(new DecisionHistoryQuery + { + TenantId = _tenantAccessor.TenantId, + Page = page, + PageSize = Math.Min(pageSize, 100), + Decision = decision, + ApplicationId = applicationId, + FromDate = fromDate, + ToDate = toDate + }, ct); + + return Ok(result); + } + + private static List ExtractFieldPaths(ConditionGroup? group) + { + if (group == null) return new(); + var fields = new List(); + foreach (var node in group.Rules) + { + if (node.IsGroup && node.Group != null) + fields.AddRange(ExtractFieldPaths(node.Group)); + else if (node.Field != null) + fields.Add(node.Field); + } + return fields.Distinct().ToList(); + } + + private static bool IsValidFieldPath(string field) => + !string.IsNullOrWhiteSpace(field) && field.Contains('.'); +} + +// ============================================================ +// REQUEST / RESPONSE DTOs +// ============================================================ + +public record BREExecutionRequest +{ + [Required] + public Dictionary Data { get; init; } = new(); + public string? CorrelationId { get; init; } + public string? ApplicationId { get; init; } + public string? ProductCode { get; init; } + public string? BranchCode { get; init; } + public string? StageCode { get; init; } + public Guid? RuleSetId { get; init; } + public string? SourceSystem { get; init; } + public bool? EnableAiAnalysis { get; init; } +} + +public record BREDecisionResponse +{ + public Guid RequestId { get; init; } + public string CorrelationId { get; init; } = default!; + public string? ApplicationId { get; init; } + public string Decision { get; init; } = default!; + public string TrafficLight { get; init; } = default!; + public decimal RiskScore { get; init; } + public string RiskCategory { get; init; } = default!; + public int TotalRulesEvaluated { get; init; } + public int RulesPassed { get; init; } + public int RulesFailed { get; init; } + public int DeviationsCount { get; init; } + public int ExecutionMs { get; init; } + public List RuleResults { get; init; } = new(); + public string? AiSummary { get; init; } + public AiAnalysisDto? AiAnalysis { get; init; } + public Guid? ReportId { get; init; } + public DateTime GeneratedAt { get; init; } +} + +public record RuleResultDto +{ + public string RuleCode { get; init; } = default!; + public string RuleName { get; init; } = default!; + public bool IsMatched { get; init; } + public List ActionsExecuted { get; init; } = new(); + public int ExecutionMs { get; init; } +} + +public record AiAnalysisDto +{ + public string RiskSummary { get; init; } = default!; + public string CreditSummary { get; init; } = default!; + public List Strengths { get; init; } = new(); + public List Weaknesses { get; init; } = new(); + public string ApprovalRecommendation { get; init; } = default!; + public List RejectionReasons { get; init; } = new(); + public List AdditionalDocuments { get; init; } = new(); + public string UnderwritingNotes { get; init; } = default!; + public double ConfidenceScore { get; init; } +} + +public record RuleValidationRequest +{ + public RuleDefinition? RuleDefinition { get; init; } + public Dictionary? SampleData { get; init; } +} + +public record RuleValidationResponse +{ + public bool IsValid { get; init; } + public List Errors { get; init; } = new(); + public List Warnings { get; init; } = new(); + public List ExtractedFields { get; init; } = new(); +} + +public record SimulationRequest +{ + public Dictionary Data { get; init; } = new(); + public Guid? RuleSetId { get; init; } + public List? RuleIds { get; init; } +} + +public record PagedResponse +{ + public List Items { get; init; } = new(); + public int TotalCount { get; init; } + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); +} + +public record DecisionSummaryDto +{ + public Guid RequestId { get; init; } + public string? ApplicationId { get; init; } + public string Decision { get; init; } = default!; + public string TrafficLight { get; init; } = default!; + public decimal RiskScore { get; init; } + public int DeviationsCount { get; init; } + public DateTime CreatedAt { get; init; } +} + +public class DecisionHistoryQuery +{ + public Guid TenantId { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 20; + public string? Decision { get; set; } + public string? ApplicationId { get; set; } + public DateTime? FromDate { get; set; } + public DateTime? ToDate { get; set; } +} + +public interface IExecutionRequestRepository +{ + Task CreateAsync(ExecutionRequest request, CancellationToken ct = default); + Task MarkCompletedAsync(Guid id, CancellationToken ct = default); +} + +public interface IExecutionResultRepository +{ + Task SaveAsync(ExecutionResult result, CancellationToken ct = default); + Task GetByRequestIdAsync(Guid requestId, Guid tenantId, CancellationToken ct = default); + Task> GetHistoryAsync(DecisionHistoryQuery query, CancellationToken ct = default); +} + +public interface ISandboxService +{ + Task SimulateAsync(SandboxRequest request, CancellationToken ct = default); +} + +public record SandboxRequest +{ + public Guid TenantId { get; init; } + public Guid? RuleSetId { get; init; } + public Dictionary TestData { get; init; } = new(); + public List? RuleIds { get; init; } + public Guid CreatedBy { get; init; } +} + +public interface IDecisionReportService +{ + Task GenerateAsync(Guid requestId, ExecutionResult result, CancellationToken ct = default); +} + +public class DecisionReport +{ + public Guid Id { get; set; } +} + +public interface ITenantContextAccessor +{ + Guid TenantId { get; } + Guid UserId { get; } + string? UserEmail { get; } +} diff --git a/BREDbContext.cs b/BREDbContext.cs new file mode 100644 index 0000000000000000000000000000000000000000..28b07664729dfd6516df30ed2dd574b7c079c537 --- /dev/null +++ b/BREDbContext.cs @@ -0,0 +1,337 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using OptimAI.BRE.Shared.Domain; +using System.Text.Json; + +namespace OptimAI.BRE.RuleEngine.Infrastructure; + +public class BREDbContext : DbContext +{ + public BREDbContext(DbContextOptions options) : base(options) { } + + public DbSet Tenants => Set(); + public DbSet TenantConfigurations => Set(); + public DbSet Users => Set(); + public DbSet Roles => Set(); + public DbSet Permissions => Set(); + public DbSet RolePermissions => Set(); + public DbSet UserRoles => Set(); + public DbSet ApiKeys => Set(); + public DbSet RefreshTokens => Set(); + public DbSet Products => Set(); + public DbSet Branches => Set(); + public DbSet LoanStages => Set(); + public DbSet RuleCategories => Set(); + public DbSet Rules => Set(); + public DbSet RuleVersions => Set(); + public DbSet RuleScopes => Set(); + public DbSet RuleSets => Set(); + public DbSet RuleSetMembers => Set(); + public DbSet FieldCatalog => Set(); + public DbSet ExecutionRequests => Set(); + public DbSet ExecutionResults => Set(); + public DbSet RuleExecutionDetails => Set(); + public DbSet DeviationTypes => Set(); + public DbSet ExecutionDeviations => Set(); + public DbSet DecisionReports => Set(); + public DbSet AuditLogs => Set(); + public DbSet RuleApprovals => Set(); + public DbSet AiGeneratedRules => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + base.OnModelCreating(model); + + // ---- TENANT ---- + model.Entity(e => + { + e.HasKey(t => t.Id); + e.HasIndex(t => t.TenantCode).IsUnique(); + e.Property(t => t.Settings) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null)!) + .HasColumnType("jsonb"); + }); + + // ---- USER ---- + model.Entity(e => + { + e.HasKey(u => u.Id); + e.HasIndex(u => new { u.TenantId, u.Email }).IsUnique(); + e.HasIndex(u => new { u.TenantId, u.Username }).IsUnique(); + e.HasOne(u => u.Tenant).WithMany(t => t.Users).HasForeignKey(u => u.TenantId); + }); + + // ---- ROLE ---- + model.Entity(e => + { + e.HasKey(r => r.Id); + e.HasIndex(r => new { r.TenantId, r.RoleCode }).IsUnique(); + }); + + model.Entity(e => + { + e.HasKey(rp => new { rp.RoleId, rp.PermissionId }); + e.HasOne(rp => rp.Role).WithMany(r => r.RolePermissions).HasForeignKey(rp => rp.RoleId); + e.HasOne(rp => rp.Permission).WithMany().HasForeignKey(rp => rp.PermissionId); + }); + + model.Entity(e => + { + e.HasKey(ur => new { ur.UserId, ur.RoleId }); + e.HasOne(ur => ur.User).WithMany(u => u.UserRoles).HasForeignKey(ur => ur.UserId); + e.HasOne(ur => ur.Role).WithMany(r => r.UserRoles).HasForeignKey(ur => ur.RoleId); + }); + + // ---- RULE ---- + model.Entity(e => + { + e.HasKey(r => r.Id); + e.HasIndex(r => new { r.TenantId, r.RuleCode }).IsUnique(); + e.HasIndex(r => new { r.TenantId, r.RuleType }).HasFilter("is_active = true AND is_published = true"); + e.Property(r => r.Tags) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null)!) + .HasColumnType("jsonb"); + e.HasOne(r => r.Category).WithMany().HasForeignKey(r => r.CategoryId); + e.HasMany(r => r.Versions).WithOne(v => v.Rule).HasForeignKey(v => v.RuleId); + e.HasMany(r => r.Scopes).WithOne(s => s.Rule).HasForeignKey(s => s.RuleId); + e.HasOne(r => r.CurrentVersion).WithMany() + .HasForeignKey(r => r.CurrentVersionId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // ---- RULE VERSION ---- + model.Entity(e => + { + e.HasKey(v => v.Id); + e.HasIndex(v => new { v.RuleId, v.VersionNumber }).IsUnique(); + e.Property(v => v.RuleDefinition) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)!) + .HasColumnType("jsonb"); + }); + + // ---- RULE SCOPE ---- + model.Entity(e => + { + e.HasKey(s => s.Id); + }); + + // ---- RULE SET ---- + model.Entity(e => + { + e.HasKey(m => new { m.SetId, m.RuleId }); + e.HasOne(m => m.RuleSet).WithMany(s => s.Members).HasForeignKey(m => m.SetId); + e.HasOne(m => m.Rule).WithMany().HasForeignKey(m => m.RuleId); + }); + + // ---- EXECUTION REQUEST ---- + model.Entity(e => + { + e.HasKey(r => r.Id); + e.HasIndex(r => r.CorrelationId).IsUnique(); + e.HasIndex(r => new { r.TenantId, r.Status, r.CreatedAt }); + e.Property(r => r.InputPayload) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null)!) + .HasColumnType("jsonb"); + }); + + // ---- EXECUTION RESULT ---- + model.Entity(e => + { + e.HasKey(r => r.Id); + e.HasIndex(r => r.RequestId); + e.Property(r => r.RuleResults) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null)!) + .HasColumnType("jsonb"); + e.Property(r => r.FieldValues) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null)!) + .HasColumnType("jsonb"); + e.Property(r => r.AiAnalysis) + .HasConversion( + v => v == null ? null : JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => v == null ? null : JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)) + .HasColumnType("jsonb"); + }); + + // ---- AUDIT LOG ---- + model.Entity(e => + { + e.HasKey(a => a.Id); + e.HasIndex(a => new { a.TenantId, a.EntityType, a.EntityId, a.CreatedAt }); + e.Property(a => a.OldValues) + .HasConversion( + v => v == null ? null : JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => v == null ? null : JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null)) + .HasColumnType("jsonb"); + e.Property(a => a.NewValues) + .HasConversion( + v => v == null ? null : JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => v == null ? null : JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null)) + .HasColumnType("jsonb"); + }); + + // ---- DEVIATION ---- + model.Entity(e => + { + e.HasKey(d => d.Id); + e.HasIndex(d => d.ResultId); + }); + + // Global query filters for soft-delete / tenant isolation + model.Entity().HasQueryFilter(r => r.IsActive); + model.Entity().HasQueryFilter(u => u.IsActive); + } + + public override Task SaveChangesAsync(CancellationToken ct = default) + { + UpdateTimestamps(); + return base.SaveChangesAsync(ct); + } + + private void UpdateTimestamps() + { + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Modified) + entry.Entity.UpdatedAt = DateTime.UtcNow; + } + } +} + +// DB entity aliases needed for EF mapping +public class TenantConfiguration : TenantEntity +{ + public string ConfigKey { get; set; } = default!; + public string? ConfigValue { get; set; } + public string ConfigType { get; set; } = "STRING"; + public string? Category { get; set; } + public bool IsEncrypted { get; set; } +} + +public class RefreshToken +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string TokenHash { get; set; } = default!; + public DateTime ExpiresAt { get; set; } + public bool IsRevoked { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public class Product : TenantEntity +{ + public string ProductCode { get; set; } = default!; + public string ProductName { get; set; } = default!; + public string ProductType { get; set; } = default!; + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + public Dictionary Config { get; set; } = new(); +} + +public class Branch : TenantEntity +{ + public string BranchCode { get; set; } = default!; + public string BranchName { get; set; } = default!; + public string? Region { get; set; } + public string? State { get; set; } + public string? City { get; set; } + public string? Zone { get; set; } + public bool IsActive { get; set; } = true; +} + +public class LoanStage : TenantEntity +{ + public string StageCode { get; set; } = default!; + public string StageName { get; set; } = default!; + public int StageOrder { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; +} + +public class FieldCatalogEntry +{ + public Guid Id { get; set; } + public Guid? TenantId { get; set; } + public string FieldPath { get; set; } = default!; + public string DisplayName { get; set; } = default!; + public string? Description { get; set; } + public string DataType { get; set; } = default!; + public string? Category { get; set; } + public bool IsSystemField { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public class RuleApproval +{ + public Guid Id { get; set; } + public Guid RuleId { get; set; } + public Guid VersionId { get; set; } + public Guid TenantId { get; set; } + public Guid RequestedBy { get; set; } + public DateTime RequestedAt { get; set; } = DateTime.UtcNow; + public Guid? ReviewedBy { get; set; } + public DateTime? ReviewedAt { get; set; } + public string Status { get; set; } = "PENDING"; + public string? Comments { get; set; } +} + +public class RuleExecutionDetail +{ + public Guid Id { get; set; } + public Guid ResultId { get; set; } + public Guid RuleId { get; set; } + public string RuleCode { get; set; } = default!; + public string RuleName { get; set; } = default!; + public int VersionNumber { get; set; } + public int ExecutionOrder { get; set; } + public bool IsMatched { get; set; } + public string ConditionsEvaluated { get; set; } = "[]"; + public string ActionsExecuted { get; set; } = "[]"; + public int? ExecutionMs { get; set; } + public string? ErrorMessage { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public class DecisionReportEntity +{ + public Guid Id { get; set; } + public Guid RequestId { get; set; } + public Guid TenantId { get; set; } + public string? ReportNumber { get; set; } + public string? ApplicationId { get; set; } + public string? ProductCode { get; set; } + public string FinalDecision { get; set; } = default!; + public decimal? RiskScore { get; set; } + public string? RiskCategory { get; set; } + public string? TrafficLight { get; set; } + public string? Summary { get; set; } + public string? ReportJson { get; set; } + public string? PdfStoragePath { get; set; } + public DateTime GeneratedAt { get; set; } = DateTime.UtcNow; +} + +public class AiGeneratedRuleEntity +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public string UserPrompt { get; set; } = default!; + public string GeneratedRule { get; set; } = "{}"; + public Guid? RuleId { get; set; } + public bool? IsAccepted { get; set; } + public Guid CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/CachedRuleLoader.cs b/CachedRuleLoader.cs new file mode 100644 index 0000000000000000000000000000000000000000..c2ecd03c306e555e926dd2c63b600ef3a4e81f3e --- /dev/null +++ b/CachedRuleLoader.cs @@ -0,0 +1,131 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using OptimAI.BRE.RuleEngine.Domain; +using OptimAI.BRE.Shared.Domain; +using System.Text.Json; + +namespace OptimAI.BRE.RuleEngine.Infrastructure; + +/// +/// Redis-backed rule loader with 5-minute cache TTL. +/// Cache key includes tenant + product + branch + stage for scope-aware loading. +/// +public sealed class CachedRuleLoader : IRuleLoader +{ + private readonly BREDbContext _db; + private readonly IDistributedCache _cache; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public CachedRuleLoader(BREDbContext db, IDistributedCache cache, ILogger logger) + { + _db = db; + _cache = cache; + _logger = logger; + } + + public async Task> LoadRulesAsync(RuleLoadRequest request, CancellationToken ct = default) + { + var cacheKey = BuildCacheKey(request); + + var cached = await _cache.GetStringAsync(cacheKey, ct); + if (cached != null) + { + _logger.LogDebug("Cache HIT for rules: {Key}", cacheKey); + return JsonSerializer.Deserialize>(cached, _jsonOpts) ?? new(); + } + + _logger.LogDebug("Cache MISS for rules: {Key}", cacheKey); + var rules = await LoadFromDatabaseAsync(request, ct); + + await _cache.SetStringAsync(cacheKey, + JsonSerializer.Serialize(rules, _jsonOpts), + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) + }, ct); + + return rules; + } + + public async Task LoadRuleByCodeAsync(Guid tenantId, string ruleCode, CancellationToken ct = default) + { + return await _db.Rules + .Include(r => r.CurrentVersion) + .Include(r => r.Scopes) + .FirstOrDefaultAsync(r => r.TenantId == tenantId && r.RuleCode == ruleCode, ct); + } + + private async Task> LoadFromDatabaseAsync(RuleLoadRequest request, CancellationToken ct) + { + var query = _db.Rules + .Include(r => r.CurrentVersion) + .Include(r => r.Scopes) + .Where(r => r.TenantId == request.TenantId); + + if (request.PublishedOnly) + query = query.Where(r => r.IsPublished && r.Status == RuleStatus.Published); + + if (request.RuleTypes.Any()) + query = query.Where(r => request.RuleTypes.Contains(r.RuleType)); + + if (request.RuleSetId.HasValue) + { + var ruleIds = await _db.RuleSetMembers + .Where(m => m.SetId == request.RuleSetId.Value) + .Select(m => m.RuleId) + .ToListAsync(ct); + + query = query.Where(r => ruleIds.Contains(r.Id)); + } + else + { + // Scope filtering: include GLOBAL rules + rules scoped to the request context + query = query.Where(r => + !r.Scopes.Any() || // no scope = global + r.Scopes.Any(s => s.ScopeType == ScopeType.Global) || + (request.ProductCode != null && r.Scopes.Any(s => + s.ScopeType == ScopeType.Product && s.ScopeValue == request.ProductCode && !s.IsExcluded)) || + (request.BranchCode != null && r.Scopes.Any(s => + s.ScopeType == ScopeType.Branch && s.ScopeValue == request.BranchCode && !s.IsExcluded)) || + (request.StageCode != null && r.Scopes.Any(s => + s.ScopeType == ScopeType.Stage && s.ScopeValue == request.StageCode && !s.IsExcluded)) + ); + } + + return await query + .OrderBy(r => r.Priority) + .AsNoTracking() + .ToListAsync(ct); + } + + public async Task InvalidateCacheAsync(Guid tenantId, CancellationToken ct = default) + { + // Invalidate all rule cache entries for this tenant + // In production, use a tag-based cache invalidation strategy + var pattern = $"bre:rules:{tenantId}:*"; + _logger.LogInformation("Invalidating rule cache for tenant {TenantId}", tenantId); + // Redis SCAN + DEL for pattern — implement via IConnectionMultiplexer if needed + } + + private static string BuildCacheKey(RuleLoadRequest request) + { + var parts = new[] + { + "bre", "rules", + request.TenantId.ToString(), + request.ProductCode ?? "ALL", + request.BranchCode ?? "ALL", + request.StageCode ?? "ALL", + request.RuleSetId?.ToString() ?? "ALL", + string.Join(",", request.RuleTypes.OrderBy(t => t.ToString())), + request.PublishedOnly ? "pub" : "all" + }; + return string.Join(":", parts); + } +} diff --git a/ConditionEvaluator.cs b/ConditionEvaluator.cs new file mode 100644 index 0000000000000000000000000000000000000000..151fc862a456a2e9665b3e9995420be573a9307c --- /dev/null +++ b/ConditionEvaluator.cs @@ -0,0 +1,167 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using OptimAI.BRE.RuleEngine.Domain; +using OptimAI.BRE.Shared.Domain; + +namespace OptimAI.BRE.RuleEngine.Application; + +public sealed class ConditionEvaluator : IConditionEvaluator +{ + private readonly IDynamicFieldResolver _fieldResolver; + private readonly ILogger _logger; + + public ConditionEvaluator(IDynamicFieldResolver fieldResolver, ILogger logger) + { + _fieldResolver = fieldResolver; + _logger = logger; + } + + public bool Evaluate(ConditionNode condition, DynamicDataContext context) + { + try + { + var actualValue = ResolveValue(condition.Field!, context); + var expectedValue = ResolveExpectedValue(condition, context); + + return condition.Operator switch + { + ComparisonOperator.Equals => AreEqual(actualValue, expectedValue), + ComparisonOperator.NotEquals => !AreEqual(actualValue, expectedValue), + ComparisonOperator.GreaterThan => Compare(actualValue, expectedValue) > 0, + ComparisonOperator.GreaterThanOrEqual => Compare(actualValue, expectedValue) >= 0, + ComparisonOperator.LessThan => Compare(actualValue, expectedValue) < 0, + ComparisonOperator.LessThanOrEqual => Compare(actualValue, expectedValue) <= 0, + ComparisonOperator.Between => EvaluateBetween(actualValue, condition, context), + ComparisonOperator.NotBetween => !EvaluateBetween(actualValue, condition, context), + ComparisonOperator.In => EvaluateIn(actualValue, condition.Value), + ComparisonOperator.NotIn => !EvaluateIn(actualValue, condition.Value), + ComparisonOperator.Contains => EvaluateContains(actualValue, expectedValue), + ComparisonOperator.NotContains => !EvaluateContains(actualValue, expectedValue), + ComparisonOperator.StartsWith => EvaluateStartsWith(actualValue, expectedValue), + ComparisonOperator.EndsWith => EvaluateEndsWith(actualValue, expectedValue), + ComparisonOperator.IsNull => actualValue == null, + ComparisonOperator.IsNotNull => actualValue != null, + ComparisonOperator.IsTrue => IsTruthy(actualValue), + ComparisonOperator.IsFalse => !IsTruthy(actualValue), + ComparisonOperator.Regex => EvaluateRegex(actualValue, expectedValue?.ToString()), + _ => false + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Condition evaluation failed for field {Field}", condition.Field); + return false; + } + } + + private object? ResolveValue(string field, DynamicDataContext context) + { + return _fieldResolver.TryResolve(field, context, out var value) ? value : null; + } + + private object? ResolveExpectedValue(ConditionNode condition, DynamicDataContext context) + { + return condition.ValueType switch + { + ValueType.Field when condition.ReferenceField != null => ResolveValue(condition.ReferenceField, context), + ValueType.Literal => condition.Value, + _ => condition.Value + }; + } + + private static bool AreEqual(object? left, object? right) + { + if (left == null && right == null) return true; + if (left == null || right == null) return false; + + if (TryToDecimal(left, out var ld) && TryToDecimal(right, out var rd)) + return ld == rd; + + return left.ToString()?.Equals(right.ToString(), StringComparison.OrdinalIgnoreCase) ?? false; + } + + private static int Compare(object? left, object? right) + { + if (left == null && right == null) return 0; + if (left == null) return -1; + if (right == null) return 1; + + if (TryToDecimal(left, out var ld) && TryToDecimal(right, out var rd)) + return ld.CompareTo(rd); + + if (left is DateTime dl && right is DateTime dr) + return dl.CompareTo(dr); + + return string.Compare(left.ToString(), right.ToString(), StringComparison.OrdinalIgnoreCase); + } + + private bool EvaluateBetween(object? actual, ConditionNode condition, DynamicDataContext context) + { + var value1 = ResolveExpectedValue(condition, context); + var value2 = condition.Value2 != null ? (object)condition.Value2 : null; + + if (value1 == null || value2 == null) return false; + return Compare(actual, value1) >= 0 && Compare(actual, value2) <= 0; + } + + private static bool EvaluateIn(object? actual, object? values) + { + if (actual == null || values == null) return false; + + IEnumerable list = values switch + { + IEnumerable e => e, + string s => s.Split(',').Select(x => (object)x.Trim()), + _ => new[] { values } + }; + + return list.Any(v => AreEqual(actual, v)); + } + + private static bool EvaluateContains(object? actual, object? expected) + { + if (actual == null || expected == null) return false; + + if (actual is IEnumerable list) + return list.Any(item => AreEqual(item, expected)); + + return actual.ToString()?.Contains(expected.ToString() ?? "", StringComparison.OrdinalIgnoreCase) ?? false; + } + + private static bool EvaluateStartsWith(object? actual, object? expected) + => actual?.ToString()?.StartsWith(expected?.ToString() ?? "", StringComparison.OrdinalIgnoreCase) ?? false; + + private static bool EvaluateEndsWith(object? actual, object? expected) + => actual?.ToString()?.EndsWith(expected?.ToString() ?? "", StringComparison.OrdinalIgnoreCase) ?? false; + + private static bool EvaluateRegex(object? actual, string? pattern) + { + if (actual == null || pattern == null) return false; + return Regex.IsMatch(actual.ToString()!, pattern, RegexOptions.IgnoreCase); + } + + private static bool IsTruthy(object? value) => value switch + { + bool b => b, + decimal d => d != 0, + int i => i != 0, + string s => !string.IsNullOrEmpty(s) && s != "0" && s.ToLower() != "false", + null => false, + _ => true + }; + + private static bool TryToDecimal(object value, out decimal result) + { + result = 0; + return value switch + { + decimal d => (result = d) == d, + double d => (result = (decimal)d) == result, + float f => (result = (decimal)f) == result, + int i => (result = i) == i, + long l => (result = l) == l, + string s => decimal.TryParse(s, out result), + _ => false + }; + } +} diff --git a/ConditionGroupEditor.tsx b/ConditionGroupEditor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0a1c6a5385c2b7d3c0614a037c7265d9607b086d --- /dev/null +++ b/ConditionGroupEditor.tsx @@ -0,0 +1,166 @@ +'use client' + +import React from 'react' +import { v4 as uuidv4 } from 'uuid' +import type { ConditionGroup, ConditionNode, FieldCatalogEntry, LogicalOperator, ComparisonOperator } from '@/types' +import { ConditionNodeEditor } from './ConditionNodeEditor' + +interface Props { + group: ConditionGroup + fieldCatalog: FieldCatalogEntry[] + onChange: (group: ConditionGroup) => void + readOnly?: boolean + depth: number +} + +const GROUP_COLORS = [ + 'border-blue-300 bg-blue-50', + 'border-purple-300 bg-purple-50', + 'border-teal-300 bg-teal-50', + 'border-orange-300 bg-orange-50', +] + +export function ConditionGroupEditor({ group, fieldCatalog, onChange, readOnly, depth }: Props) { + const colorClass = GROUP_COLORS[depth % GROUP_COLORS.length] + + const updateOperator = (operator: LogicalOperator) => + onChange({ ...group, operator }) + + const addCondition = () => { + const newNode: ConditionNode = { + id: uuidv4(), + isGroup: false, + field: '', + operator: 'EQUALS', + value: '', + valueType: 'LITERAL', + } + onChange({ ...group, rules: [...group.rules, newNode] }) + } + + const addGroup = () => { + const nestedGroup: ConditionGroup = { + id: uuidv4(), + operator: 'AND', + rules: [], + } + const newNode: ConditionNode = { + id: uuidv4(), + isGroup: true, + group: nestedGroup, + } + onChange({ ...group, rules: [...group.rules, newNode] }) + } + + const updateNode = (index: number, updated: ConditionNode) => { + const next = [...group.rules] + next[index] = updated + onChange({ ...group, rules: next }) + } + + const deleteNode = (index: number) => { + onChange({ ...group, rules: group.rules.filter((_, i) => i !== index) }) + } + + return ( +
+ {/* Group Header */} +
+ + Match + +
+ {(['AND', 'OR', 'NOT'] as LogicalOperator[]).map((op) => ( + + ))} +
+ + {group.operator === 'AND' ? 'all of the following' : group.operator === 'OR' ? 'any of the following' : 'NOT the following'} + +
+ + {/* Conditions */} +
+ {group.rules.length === 0 && ( +
+ {readOnly ? 'No conditions' : 'Add conditions below'} +
+ )} + {group.rules.map((node, idx) => ( +
+ {idx > 0 && ( +
+ {group.operator} +
+ )} +
+ {node.isGroup && node.group ? ( + + updateNode(idx, { ...node, group: updated }) + } + /> + ) : ( + updateNode(idx, updated)} + onDelete={() => deleteNode(idx)} + /> + )} +
+ {!readOnly && ( + + )} +
+ ))} +
+ + {/* Add Buttons */} + {!readOnly && ( +
+ + {depth < 3 && ( + + )} +
+ )} +
+ ) +} diff --git a/ConditionNodeEditor.tsx b/ConditionNodeEditor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ce886f7ac7dd54320891ff4cad8035b14085ac76 --- /dev/null +++ b/ConditionNodeEditor.tsx @@ -0,0 +1,197 @@ +'use client' + +import React, { useState } from 'react' +import type { ConditionNode, FieldCatalogEntry, ComparisonOperator } from '@/types' + +interface Props { + node: ConditionNode + fieldCatalog: FieldCatalogEntry[] + onChange: (node: ConditionNode) => void + onDelete: () => void + readOnly?: boolean +} + +const OPERATORS: { value: ComparisonOperator; label: string; showValue: 'single' | 'double' | 'none' | 'multi' }[] = [ + { value: 'EQUALS', label: '= Equals', showValue: 'single' }, + { value: 'NOT_EQUALS', label: '≠ Not Equals', showValue: 'single' }, + { value: 'GREATER_THAN', label: '> Greater Than', showValue: 'single' }, + { value: 'GREATER_THAN_OR_EQUAL', label: '≥ Greater Than or Equal', showValue: 'single' }, + { value: 'LESS_THAN', label: '< Less Than', showValue: 'single' }, + { value: 'LESS_THAN_OR_EQUAL', label: '≤ Less Than or Equal', showValue: 'single' }, + { value: 'BETWEEN', label: '↔ Between', showValue: 'double' }, + { value: 'IN', label: '∈ In (comma separated)', showValue: 'single' }, + { value: 'NOT_IN', label: '∉ Not In', showValue: 'single' }, + { value: 'CONTAINS', label: '⊆ Contains', showValue: 'single' }, + { value: 'IS_NULL', label: '∅ Is Empty/Null', showValue: 'none' }, + { value: 'IS_NOT_NULL', label: '• Is Not Null', showValue: 'none' }, + { value: 'IS_TRUE', label: '✓ Is True', showValue: 'none' }, + { value: 'IS_FALSE', label: '✗ Is False', showValue: 'none' }, + { value: 'REGEX', label: '⚙ Matches Regex', showValue: 'single' }, +] + +const FIELD_CATEGORIES = ['BUREAU', 'INCOME', 'PERSONAL', 'EMPLOYMENT', 'VEHICLE', 'FI', 'LOAN', 'RATIOS', 'FRAUD', 'GST', 'ITR', 'KYC'] + +export function ConditionNodeEditor({ node, fieldCatalog, onChange, onDelete, readOnly }: Props) { + const [showFieldSearch, setShowFieldSearch] = useState(false) + const [fieldSearch, setFieldSearch] = useState('') + + const selectedOp = OPERATORS.find((o) => o.value === node.operator) + const selectedField = fieldCatalog.find((f) => f.fieldPath === node.field) + + const filteredFields = fieldCatalog.filter( + (f) => + !fieldSearch || + f.fieldPath.toLowerCase().includes(fieldSearch.toLowerCase()) || + f.displayName.toLowerCase().includes(fieldSearch.toLowerCase()) + ) + + const groupedFields = FIELD_CATEGORIES.reduce>((acc, cat) => { + const fields = filteredFields.filter((f) => f.category === cat) + if (fields.length > 0) acc[cat] = fields + return acc + }, {}) + + return ( +
+ {/* Field Selector */} +
+ + + {showFieldSearch && !readOnly && ( +
+
+ setFieldSearch(e.target.value)} + placeholder="Search fields..." + className="w-full text-sm px-2 py-1.5 border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400" + /> +
+ {Object.entries(groupedFields).map(([cat, fields]) => ( +
+
+ {cat} +
+ {fields.map((f) => ( + + ))} +
+ ))} +
+ )} +
+ + {/* Operator Selector */} + + + {/* Value Input(s) */} + {selectedOp?.showValue === 'single' && ( + onChange({ ...node, value: v })} + placeholder="Value" + /> + )} + + {selectedOp?.showValue === 'double' && ( + <> + onChange({ ...node, value: v })} + placeholder="From" + /> + and + onChange({ ...node, value2: v })} + placeholder="To" + /> + + )} + + {/* Field badge */} + {node.field && ( + + {node.field} + + )} +
+ ) +} + +function ValueInput({ + value, + fieldType, + onChange, + placeholder, + readOnly, +}: { + value: string + fieldType?: string + onChange: (v: string) => void + placeholder: string + readOnly?: boolean +}) { + if (fieldType === 'BOOLEAN') { + return ( + + ) + } + + return ( + onChange(e.target.value)} + placeholder={placeholder} + 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" + /> + ) +} diff --git a/DashboardPage.tsx b/DashboardPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..37084cfbf975bb091e085198edcd7374a6a43e08 --- /dev/null +++ b/DashboardPage.tsx @@ -0,0 +1,269 @@ +'use client' + +import React from 'react' +import { useQuery } from '@tanstack/react-query' +import { + LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend +} from 'recharts' +import type { DashboardStats, ExecutionTrend, RuleHitAnalysis, DeviationAnalysisSummary } from '@/types' +import { apiClient } from '@/lib/api-client' + +const TRAFFIC_COLORS = { + approved: '#10b981', + rejected: '#ef4444', + deviations: '#f59e0b', +} + +export function DashboardPage() { + const { data: stats } = useQuery({ + queryKey: ['dashboard-stats'], + queryFn: () => apiClient.get('/analytics/dashboard-stats'), + refetchInterval: 30_000, + }) + + const { data: trends } = useQuery({ + queryKey: ['execution-trends'], + queryFn: () => apiClient.get('/analytics/execution-trends?days=30'), + }) + + const { data: topRules } = useQuery({ + queryKey: ['rule-hit-analysis'], + queryFn: () => apiClient.get('/analytics/rule-hit-analysis?limit=10'), + }) + + const { data: deviations } = useQuery({ + queryKey: ['deviation-analysis'], + queryFn: () => apiClient.get('/analytics/deviation-analysis'), + }) + + const decisionData = stats + ? [ + { name: 'Approved', value: stats.approvalRate, color: '#10b981' }, + { name: 'Rejected', value: stats.rejectionRate, color: '#ef4444' }, + { name: 'Deviation', value: stats.deviationRate, color: '#f59e0b' }, + ] + : [] + + return ( +
+ {/* Header */} +
+
+

OPTIM AI BRE Dashboard

+

Real-time rule engine analytics and insights

+
+
+
+ Live +
+
+ + {/* KPI Cards */} +
+ + + + +
+ +
+ + + + +
+ + {/* Charts Row 1 */} +
+ {/* Execution Trend */} +
+

Decision Trend (30 Days)

+ + + + + + + + + + + + +
+ + {/* Decision Distribution */} +
+

Decision Distribution

+ + + + {decisionData.map((entry, idx) => ( + + ))} + + `${Number(v).toFixed(1)}%`} /> + + +
+ {decisionData.map((d) => ( +
+
+
+ {d.name} +
+ {d.value?.toFixed(1)}% +
+ ))} +
+
+
+ + {/* Charts Row 2 */} +
+ {/* Top Rules by Hit Rate */} +
+

Top Rules by Hit Rate

+ + + + + + `${Number(v).toFixed(1)}%`} /> + + + +
+ + {/* Top Deviations */} +
+

Top Deviations

+

Most frequent policy deviations

+
+ {(deviations ?? []).slice(0, 10).map((d, idx) => ( +
+ {idx + 1} +
+
+ {d.deviationName} + +
+
+
+
+
+ {d.occurrenceCount} +
+ ))} +
+
+
+
+ ) +} + +function KPICard({ + label, value, sub, color, icon +}: { + label: string; value: string; sub: string; color: string; icon: string +}) { + const colorMap: Record = { + blue: 'bg-blue-50 text-blue-700', + emerald: 'bg-emerald-50 text-emerald-700', + red: 'bg-red-50 text-red-700', + amber: 'bg-amber-50 text-amber-700', + purple: 'bg-purple-50 text-purple-700', + orange: 'bg-orange-50 text-orange-700', + teal: 'bg-teal-50 text-teal-700', + indigo: 'bg-indigo-50 text-indigo-700', + } + + return ( +
+
+
+

{label}

+

{value}

+

{sub}

+
+
+ {icon} +
+
+
+ ) +} + +function SeverityBadge({ severity }: { severity: string }) { + const colors: Record = { + LOW: 'bg-blue-100 text-blue-700', + MEDIUM: 'bg-yellow-100 text-yellow-700', + HIGH: 'bg-orange-100 text-orange-700', + CRITICAL: 'bg-red-100 text-red-700', + } + return ( + + {severity} + + ) +} diff --git a/DeviationManagementPage.tsx b/DeviationManagementPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c6eb9f69f2006d078e20d1f96d30689ca954bd6d --- /dev/null +++ b/DeviationManagementPage.tsx @@ -0,0 +1,287 @@ +'use client' + +import React, { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import type { PagedResponse } from '@/types' +import { apiClient } from '@/lib/api-client' + +interface DeviationRecord { + id: string + requestId: string + applicationId?: string + deviationCode: string + deviationName: string + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + reason: string + fieldPath?: string + actualValue?: string + expectedValue?: string + recommendedAction?: string + isOverridden: boolean + overrideReason?: string + createdAt: string + ruleCode?: string +} + +interface DeviationType { + id: string + deviationCode: string + deviationName: string + category: string + defaultSeverity: string + description: string + recommendedAction: string + requiresApproval: boolean +} + +const SEVERITY_CONFIG = { + LOW: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200', dot: 'bg-blue-500' }, + MEDIUM: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200', dot: 'bg-yellow-500' }, + HIGH: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200', dot: 'bg-orange-500' }, + CRITICAL: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', dot: 'bg-red-500' }, +} + +export function DeviationManagementPage() { + const qc = useQueryClient() + const [activeTab, setActiveTab] = useState<'live' | 'types'>('live') + const [selectedSeverity, setSelectedSeverity] = useState('') + const [search, setSearch] = useState('') + const [overrideModal, setOverrideModal] = useState(null) + const [overrideReason, setOverrideReason] = useState('') + + const { data: deviations } = useQuery>({ + queryKey: ['deviations', selectedSeverity, search], + queryFn: () => apiClient.get('/deviations', { severity: selectedSeverity, search }), + refetchInterval: 30_000, + }) + + const { data: deviationTypes } = useQuery>({ + queryKey: ['deviation-types'], + queryFn: () => apiClient.get('/deviation-types'), + }) + + const overrideMutation = useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + apiClient.post(`/deviations/${id}/override`, { reason }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['deviations'] }) + setOverrideModal(null) + setOverrideReason('') + }, + }) + + const stats = { + total: deviations?.totalCount ?? 0, + critical: deviations?.items.filter((d) => d.severity === 'CRITICAL').length ?? 0, + overridden: deviations?.items.filter((d) => d.isOverridden).length ?? 0, + pending: deviations?.items.filter((d) => !d.isOverridden).length ?? 0, + } + + return ( +
+
+
+

Deviation Management

+

Track, analyze, and override policy deviations

+
+
+ + {/* Stats */} +
+ {[ + { label: 'Total Deviations', value: stats.total, icon: '📋', color: 'blue' }, + { label: 'Critical', value: stats.critical, icon: '🚨', color: 'red' }, + { label: 'Pending Override', value: stats.pending, icon: '⏳', color: 'orange' }, + { label: 'Overridden', value: stats.overridden, icon: '✅', color: 'emerald' }, + ].map(({ label, value, icon, color }) => ( +
+
+
+

{label}

+

{value}

+
+
{icon}
+
+
+ ))} +
+ + {/* Tabs */} +
+ {[ + { id: 'live', label: 'Live Deviations' }, + { id: 'types', label: 'Deviation Types' }, + ].map(({ id, label }) => ( + + ))} +
+ + {activeTab === 'live' && ( +
+ {/* Filters */} +
+ setSearch(e.target.value)} + placeholder="Search deviations..." + 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" + /> + +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {deviations?.items.map((d) => { + const sc = SEVERITY_CONFIG[d.severity] + return ( + + + + + + + + + + ) + })} + +
DeviationSeverityApplicationActual ValueReasonStatusAction
+
{d.deviationName}
+
{d.deviationCode}
+
+ + + {d.severity} + + {d.applicationId ?? '—'} + {d.fieldPath && ( +
+
{d.fieldPath}
+
{d.actualValue ?? '—'}
+
+ )} +
{d.reason} + {d.isOverridden ? ( + Overridden + ) : ( + Pending + )} + + {!d.isOverridden && ( + + )} +
+
+
+ )} + + {activeTab === 'types' && ( +
+ {deviationTypes?.items.map((dt) => { + const sc = SEVERITY_CONFIG[dt.defaultSeverity as keyof typeof SEVERITY_CONFIG] ?? SEVERITY_CONFIG.MEDIUM + return ( +
+
+
{dt.deviationName}
+ + {dt.defaultSeverity} + +
+
{dt.deviationCode}
+
{dt.description}
+ {dt.recommendedAction && ( +
+ 💡 + {dt.recommendedAction} +
+ )} + {dt.requiresApproval && ( +
⚠ Requires approval
+ )} +
+ ) + })} +
+ )} + + {/* Override Modal */} + {overrideModal && ( +
+
+
+

Override Deviation

+

{overrideModal.deviationName}

+
+
+
+

⚠ Override Reason Required

+

You must provide a valid business justification for overriding this deviation. This action will be logged in the audit trail.

+
+
+ +