using GB5Shared.DTO.PubSub; using Microsoft.Extensions.Logging; using System; using System.Text.Json; namespace GB5Shared.RuleEngine { /// /// Evaluates simple condition expressions against an /// using for payload field access. /// No dynamic compilation at evaluation time — safe for multi-tenant scenarios. /// /// Supported expression format: "LHS operator RHS" /// LHS: a property path, either: /// - Top-level field of OutboxEventMessage: EventTypeId, ObjectTypeId, ObjectId, TenantId, UserId /// - Payload field: Payload.SomeField (traverses one level of the JSON payload) /// Operators: == != > < >= <= /// RHS: a literal (number or string, no quotes) /// /// Fail-open policy: on any parse/evaluation error the method logs a warning and returns true, /// so a misconfigured expression never silently suppresses all action delivery. /// public sealed class JsonPathConditionEvaluator : IConditionEvaluator { private readonly ILogger _logger; public JsonPathConditionEvaluator(ILogger logger) => _logger = logger; /// public bool Evaluate(string? expression, OutboxEventMessage message) { if (string.IsNullOrWhiteSpace(expression)) return true; // null/empty = always trigger try { var (lhsPath, op, rhs) = Parse(expression); var lhsValue = ResolveLeft(lhsPath, message); if (lhsValue is null) { _logger.LogDebug( "ConditionEvaluator: LHS path '{Path}' resolved to null for EventTypeId={EventTypeId} — treating as false", lhsPath, message.EventTypeId); return false; } return Compare(lhsValue, op, rhs); } catch (Exception ex) { _logger.LogWarning(ex, "ConditionEvaluator: failed to evaluate expression '{Expression}' for EventTypeId={EventTypeId} — defaulting to true (fail-open)", expression, message.EventTypeId); return true; // fail-open } } // ── Parser ─────────────────────────────────────────────────────────────── private static readonly string[] _operators = [">=", "<=", "!=", "==", ">", "<"]; private static (string lhs, string op, string rhs) Parse(string expression) { foreach (var op in _operators) { var idx = expression.IndexOf(op, StringComparison.Ordinal); if (idx < 0) continue; var lhs = expression[..idx].Trim(); var rhs = expression[(idx + op.Length)..].Trim(); if (string.IsNullOrEmpty(lhs) || string.IsNullOrEmpty(rhs)) continue; return (lhs, op, rhs); } throw new ArgumentException( $"ConditionEvaluator: cannot parse expression — no supported operator found: '{expression}'"); } // ── LHS resolver ───────────────────────────────────────────────────────── private static string? ResolveLeft(string path, OutboxEventMessage msg) { // Top-level OutboxEventMessage properties var resolved = path switch { "EventTypeId" => msg.EventTypeId.ToString(), "ObjectTypeId" => msg.ObjectTypeId.ToString(), "ObjectId" => msg.ObjectId.ToString(), "TenantId" => msg.TenantId.ToString(), "UserId" => msg.UserId.ToString(), _ => null }; if (resolved is not null) return resolved; // Payload.FieldName — one-level JSON traversal if (path.StartsWith("Payload.", StringComparison.OrdinalIgnoreCase)) { var fieldName = path["Payload.".Length..]; return ResolvePayloadField(msg.Payload, fieldName); } return null; // unknown path } private static string? ResolvePayloadField(string? payloadJson, string fieldName) { if (string.IsNullOrWhiteSpace(payloadJson)) return null; try { using var doc = JsonDocument.Parse(payloadJson); var root = doc.RootElement; // Case-insensitive property search within the payload foreach (var prop in root.EnumerateObject()) { if (!string.Equals(prop.Name, fieldName, StringComparison.OrdinalIgnoreCase)) continue; return prop.Value.ValueKind switch { JsonValueKind.String => prop.Value.GetString(), JsonValueKind.Number => prop.Value.GetRawText(), JsonValueKind.True => "true", JsonValueKind.False => "false", JsonValueKind.Null => null, _ => prop.Value.GetRawText() }; } } catch (JsonException) { // Malformed payload — return null; caller will treat as false } return null; } // ── Comparator ─────────────────────────────────────────────────────────── private static bool Compare(string lhs, string op, string rhs) { // Try numeric comparison first; fall back to string comparison if (decimal.TryParse(lhs, out var lhsNum) && decimal.TryParse(rhs, out var rhsNum)) { return op switch { "==" => lhsNum == rhsNum, "!=" => lhsNum != rhsNum, ">" => lhsNum > rhsNum, "<" => lhsNum < rhsNum, ">=" => lhsNum >= rhsNum, "<=" => lhsNum <= rhsNum, _ => throw new ArgumentException($"Unknown operator: {op}") }; } // String comparison (ordinal, case-insensitive) var cmp = string.Compare(lhs, rhs, StringComparison.OrdinalIgnoreCase); return op switch { "==" => cmp == 0, "!=" => cmp != 0, ">" => cmp > 0, "<" => cmp < 0, ">=" => cmp >= 0, "<=" => cmp <= 0, _ => throw new ArgumentException($"Unknown operator: {op}") }; } } }