using GB5Shared.GOP.Qualifier; using GB5Shared.GOP.Qualifier.DTOs; using GB5Shared.GOP.Qualifier.Executors; using FrameworkBLL.EIPConversation.EIPHandlers.EIPEngine.CapabilityEngine; using FrameworkBLL.EIPConversation.EIPHandlers.EIPFlowRepository; using FrameworkBLL.EIPConversation.EIPHandlers.UrlResolver; using FrameworkBLL.GOP; using FrameworkBLL.MessageHub.MessageHubGenerator; using FrameworkDAL.CustomCode.EIPConversation.EIPStepExecutionLog; using FrameworkDAL.DTO.EIPConversation; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Qualifier; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace FrameworkBLL.EIPConversation.EIPHandlers.EIPEngine.FlowEngine { public class EIPFlowEngine : IEIPFlowEngine { private const int MAX_STEP_EXECUTION = 50; private readonly IEIPFlowRepository _flowRepository; private readonly IEIPCapabilityEngine _capabilityEngine; private readonly IMessageHubGeneratorBLL _messageHubGeneratorBLL; private readonly IHttpClientFactory _httpClientFactory; private readonly IEIPUrlResolver _urlResolver; private readonly IQualifierFacadeImpl _qualifierFacade; private readonly IMappingEngineBLL _mappingEngine; private readonly IEIPStepExecutionLogDAL _stepExecutionLogDAL; private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly Dictionary>> _stepHandlers; public EIPFlowEngine( IEIPFlowRepository flowRepository, IEIPCapabilityEngine capabilityEngine, IMessageHubGeneratorBLL messageHubGeneratorBLL, IHttpClientFactory httpClientFactory, IEIPUrlResolver urlResolver, IQualifierFacadeImpl qualifierFacade, IMappingEngineBLL mappingEngine, IEIPStepExecutionLogDAL stepExecutionLogDAL, ILogger logger, IConfiguration configuration) { _flowRepository = flowRepository ?? throw new ArgumentNullException(nameof(flowRepository)); _capabilityEngine = capabilityEngine ?? throw new ArgumentNullException(nameof(capabilityEngine)); _messageHubGeneratorBLL = messageHubGeneratorBLL ?? throw new ArgumentNullException(nameof(messageHubGeneratorBLL)); _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _urlResolver = urlResolver ?? throw new ArgumentNullException(nameof(urlResolver)); _qualifierFacade = qualifierFacade ?? throw new ArgumentNullException(nameof(qualifierFacade)); _mappingEngine = mappingEngine ?? throw new ArgumentNullException(nameof(mappingEngine)); _stepExecutionLogDAL = stepExecutionLogDAL ?? throw new ArgumentNullException(nameof(stepExecutionLogDAL)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); _stepHandlers = RegisterStepHandlers(); } // ========================================================= // MAIN FLOW EXECUTION // ========================================================= public async Task ExecuteFlowAsync( EIPExecutionContextDTO context, string flowCode, LoginDTO? loginDTO = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(flowCode)) throw new ArgumentException("Flow code is required.", nameof(flowCode)); cancellationToken.ThrowIfCancellationRequested(); var login = loginDTO ?? context.LoginDTO; _logger.LogInformation( "FLOW | START | CorrelationId={CorrelationId} | Tenant={Tenant} | Flow={FlowCode} | CurrentStep={CurrentStep}", context.CorrelationId, context.TenantId, flowCode, context.CurrentStepCode ?? "START"); var flow = await _flowRepository.GetFlowDefinitionAsync( flowCode, login, cancellationToken); if (flow == null) throw new InvalidOperationException($"Flow not found: {flowCode}"); return await ExecuteStepsAsync(flow, context, login, cancellationToken).ConfigureAwait(false); } public async Task ExecuteFlowFromDefinitionAsync( EIPExecutionContextDTO context, EIPFlowDefinitionDTO flowDefinition, LoginDTO? loginDTO = null, CancellationToken cancellationToken = default) { if (flowDefinition == null) throw new ArgumentNullException(nameof(flowDefinition)); cancellationToken.ThrowIfCancellationRequested(); var login = loginDTO ?? context.LoginDTO; _logger.LogInformation( "FLOW | START (DEFINITION) | CorrelationId={CorrelationId} | Flow={FlowCode} | CurrentStep={CurrentStep}", context.CorrelationId, flowDefinition.FlowCode, context.CurrentStepCode ?? "START"); return await ExecuteStepsAsync(flowDefinition, context, login, cancellationToken).ConfigureAwait(false); } // Shared step-execution loop used by both public entry points. private async Task ExecuteStepsAsync( EIPFlowDefinitionDTO flow, EIPExecutionContextDTO context, LoginDTO login, CancellationToken cancellationToken) { string currentStepCode = context.CurrentStepCode ?? flow.StartStepCode; int executionCount = 0; while (true) { cancellationToken.ThrowIfCancellationRequested(); if (executionCount++ > MAX_STEP_EXECUTION) { _logger.LogError( "FLOW | ERROR | Max execution exceeded | CorrelationId={CorrelationId}", context.CorrelationId); throw new InvalidOperationException($"Flow exceeded max step execution limit ({MAX_STEP_EXECUTION})."); } if (!flow.Steps.TryGetValue(currentStepCode, out var step)) { _logger.LogError( "FLOW | ERROR | Step not found | StepCode={StepCode} | CorrelationId={CorrelationId}", currentStepCode, context.CorrelationId); throw new InvalidOperationException($"Step not found: {currentStepCode}"); } _logger.LogInformation( "FLOW | STEP START | CorrelationId={CorrelationId} | Step={StepCode} | Type={Type}", context.CorrelationId, step.StepCode, step.StepType); // Terminal step (StepType=END or IsTerminal=true) — complete immediately. // No handler needed; ensures sessions saved at STEP_END are expired cleanly // rather than throwing "Unsupported step type: END". if (step.IsTerminal || step.StepType.Equals("END", StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation( "FLOW | TERMINAL | CorrelationId={CorrelationId} | Step={StepCode}", context.CorrelationId, step.StepCode); return new EIPFlowExecutionResultDTO { IsCompleted = true, CurrentStepCode = step.StepCode, FlowCode = flow.FlowCode, ResponseMessage = string.IsNullOrWhiteSpace(step.MessageTemplate) ? null : SubstituteContext(step.MessageTemplate, context.Variables), MessageKey = step.MessageKey }; } var stepStopwatch = Stopwatch.StartNew(); EIPFlowExecutionResultDTO result; try { result = await ExecuteStepDynamicAsync(step, context, login, cancellationToken); stepStopwatch.Stop(); await LogStepExecutionAsync( context, step, stepStopwatch.ElapsedMilliseconds, stepStatus: 2 /*Completed*/, errorMessage: null, login, cancellationToken); } catch (Exception stepEx) when (stepEx is not OperationCanceledException) { stepStopwatch.Stop(); await LogStepExecutionAsync( context, step, stepStopwatch.ElapsedMilliseconds, stepStatus: 3 /*Error*/, errorMessage: stepEx.Message, login, cancellationToken); throw; } result.FlowCode = flow.FlowCode; result.CurrentStepCode = step.StepCode; result.NextStepCode ??= step.NextStepCode; _logger.LogInformation( "FLOW | STEP RESULT | Step={Step} | NextStep={NextStep} | Completed={Completed} | Message={Message}", step.StepCode, result.NextStepCode, result.IsCompleted, result.ResponseMessage); if (string.IsNullOrWhiteSpace(result.NextStepCode)) result.IsCompleted = true; if (result.IsCompleted) { _logger.LogInformation( "FLOW | COMPLETED | CorrelationId={CorrelationId} | FinalStep={Step} | Message={Message}", context.CorrelationId, step.StepCode, result.ResponseMessage); return result; } // ========================================================= // PAUSE LOGIC (critical for WhatsApp / chat flows) // ========================================================= if (ShouldPauseExecution(step, result)) { _logger.LogInformation( "FLOW | PAUSED | CorrelationId={CorrelationId} | Step={Step} | Reason={Reason} | NextStep={NextStep}", context.CorrelationId, step.StepCode, step.StepType.ToUpperInvariant(), result.NextStepCode); return result; } // After an input-capturing step successfully matches and advances (no ResponseMessage), // clear NormalizedMessage so downstream steps don't inherit the old user reply. // Example without this: "1374" (task ID) bleeds into STEP_CHOOSE_ACTION and // is treated as an invalid Approve/Reject/Return selection. if (step.StepType.Equals("INPUT", StringComparison.OrdinalIgnoreCase) || step.StepType.Equals("CHOICE", StringComparison.OrdinalIgnoreCase) || step.StepType.Equals("DYNAMIC_CHOICE", StringComparison.OrdinalIgnoreCase)) { context.NormalizedMessage = null; } _logger.LogInformation( "FLOW | TRANSITION | From={CurrentStep} → To={NextStep} | CorrelationId={CorrelationId}", step.StepCode, result.NextStepCode, context.CorrelationId); currentStepCode = result.NextStepCode!; } } // Writes one TEIPSTEPEXECUTION row per step. Never breaks the flow if logging fails — // audit logging is observability, not a correctness dependency — and is a no-op when // InteractionSessionId is 0 (EIPConversationEngine's own interaction-session setup failed // for this turn, already logged there). private async Task LogStepExecutionAsync( EIPExecutionContextDTO context, EIPFlowStepDTO step, long durationMs, byte stepStatus, string? errorMessage, LoginDTO login, CancellationToken ct) { // InteractionSessionId is caller/AutoNumber-assigned and, per this codebase's // convention, real ids are negative (see AUTONUMBERCONSTANT.EIPINTERACTIONSESSION) — // only the unset default of 0 means "no session for this turn." if (context.InteractionSessionId == 0) return; try { byte stepTypeCode = step.StepType.ToUpperInvariant() switch { "MESSAGE" or "PROMPT" or "INPUT" => 1, "CHOICE" or "DYNAMIC_CHOICE" => 2, "CONDITION" or "CONDITIONAL" => 3, "ACTION" => 4, "CAPABILITY" => 5, "EXTERNAL_ACTION" => 6, "END" => 7, _ => 1 }; await _stepExecutionLogDAL.InsertAsync( context.InteractionSessionId, step.StepCode, stepTypeCode, stepStatus, durationMs, inputValue: context.NormalizedMessage, decisionCode: null, errorMessage, login, ct).ConfigureAwait(false); } catch (Exception logEx) { _logger.LogError(logEx, "Failed to log step execution | InteractionSessionId={Id} | Step={Step}", context.InteractionSessionId, step.StepCode); } } // Pause rules per step type: // MESSAGE/PROMPT — always pause (they are output-only, never capture input) // INPUT — pause when rendering a prompt; continue when capturing input // CHOICE — same as INPUT: pause when showing the menu (ResponseMessage set), // continue immediately when a valid reply was matched (no ResponseMessage) // DYNAMIC_CHOICE — same pattern as CHOICE/INPUT // CAPABILITY — pauses only for the OTP prompt/retry case, where HandleCapabilityAsync // deliberately loops NextStepCode back to step.StepCode (see there for why). // A normal (non-OTP) capability keeps its existing behavior — NextStepCode // is always the step's configured NextStepCode, so this never fires for it. private static bool ShouldPauseExecution(EIPFlowStepDTO step, EIPFlowExecutionResultDTO result) { if (step.StepType.Equals("MESSAGE", StringComparison.OrdinalIgnoreCase) || step.StepType.Equals("PROMPT", StringComparison.OrdinalIgnoreCase)) return true; if (step.StepType.Equals("INPUT", StringComparison.OrdinalIgnoreCase) || step.StepType.Equals("CHOICE", StringComparison.OrdinalIgnoreCase) || step.StepType.Equals("DYNAMIC_CHOICE", StringComparison.OrdinalIgnoreCase)) return !string.IsNullOrWhiteSpace(result.ResponseMessage); if (step.StepType.Equals("CAPABILITY", StringComparison.OrdinalIgnoreCase)) return string.Equals(result.NextStepCode, step.StepCode, StringComparison.OrdinalIgnoreCase); return false; } // ========================================================= // 🔥 DYNAMIC STEP EXECUTION (CORE CHANGE) // ========================================================= /// /// Resolves and executes step dynamically without switch-case. /// private async Task ExecuteStepDynamicAsync( EIPFlowStepDTO step, EIPExecutionContextDTO context, LoginDTO loginDTO, CancellationToken cancellationToken) { if (!_stepHandlers.TryGetValue(step.StepType.ToUpperInvariant(), out var handler)) throw new InvalidOperationException($"Unsupported step type: {step.StepType}"); var result = await handler(step, context, loginDTO, cancellationToken); // 🔥 Execute actions if available (future-ready) await ExecuteActionsAsync(step, context, cancellationToken); return result; } // ========================================================= // 🔥 REGISTER STEP HANDLERS // ========================================================= /// /// Registers all supported step handlers in a dictionary. /// Makes engine fully extensible without modifying execution logic. /// // ========================================================= // REGISTER STEP HANDLERS // ========================================================= private Dictionary>> RegisterStepHandlers() { return new Dictionary>>(StringComparer.OrdinalIgnoreCase) { ["PROMPT"] = async (s, c, l, t) => HandleMessage(s, c), ["MESSAGE"] = async (s, c, l, t) => HandleMessage(s, c), ["INPUT"] = async (s, c, l, t) => HandleInput(s, c), ["CONDITION"] = async (s, c, l, t) => HandleCondition(s, c), ["CONDITIONAL"] = async (s, c, l, t) => HandleConditional(s, c), ["CHOICE"] = async (s, c, l, t) => HandleChoice(s, c), ["ACTION"] = HandleActionAsync, ["CAPABILITY"] = HandleCapabilityAsync, ["EXTERNAL_ACTION"] = async (s, c, l, t) => HandleExternalAction(s, c), ["PRESENT"] = async (s, c, l, t) => HandlePresent(s, c), ["QUALIFY"] = HandleQualifyAsync, ["ENRICH"] = HandleEnrichAsync, ["DYNAMIC_CHOICE"] = HandleDynamicChoiceAsync, ["LOCATION"] = async (s, c, l, t) => HandleLocation(s, c), ["LINK"] = async (s, c, l, t) => HandleLink(s, c), ["TRANSFORM"] = async (s, c, l, t) => HandleTransform(s, c), ["END"] = async (s, c, l, t) => HandleEnd(s), }; } // ========================================================= // MESSAGE / PROMPT // ========================================================= private EIPFlowExecutionResultDTO HandleMessage(EIPFlowStepDTO step, EIPExecutionContextDTO context) { var template = string.IsNullOrWhiteSpace(step.MessageTemplate) ? $"[ERROR: MessageTemplate missing for Step={step.StepCode}]" : step.MessageTemplate; return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode, ResponseMessage = SubstituteContext(template, context.Variables), MessageKey = step.MessageKey, QrCodeData = string.IsNullOrWhiteSpace(step.QrCodeData) ? null : SubstituteContext(step.QrCodeData, context.Variables), VoiceHint = string.IsNullOrWhiteSpace(step.VoiceHint) ? null : SubstituteContext(step.VoiceHint, context.Variables) }; } // ========================================================= // INPUT // ========================================================= private EIPFlowExecutionResultDTO HandleInput(EIPFlowStepDTO step, EIPExecutionContextDTO context) { var input = context.NormalizedMessage?.Trim() ?? string.Empty; var originalInput = context.OriginalMessage?.Trim() ?? string.Empty; var inputType = step.Validation?.InputType ?? "text"; var ratingMax = step.Validation?.RatingMax ?? 5; // First visit — no input yet; send prompt with input hint if (string.IsNullOrWhiteSpace(input)) { return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.StepCode, ResponseMessage = SubstituteContext(step.MessageTemplate ?? "Please provide input.", context.Variables), MessageKey = step.MessageKey, InputHint = inputType, RatingMax = step.Validation?.RatingMax, QrCodeData = string.IsNullOrWhiteSpace(step.QrCodeData) ? null : SubstituteContext(step.QrCodeData, context.Variables), VoiceHint = step.VoiceHint }; } // Required check if (step.Validation?.Required == true && string.IsNullOrWhiteSpace(input)) return InputError(step, context, step.Validation.ErrorMessage ?? "This field is required.", inputType, ratingMax); // Regex check if (!string.IsNullOrWhiteSpace(step.Validation?.Regex) && !Regex.IsMatch(input, step.Validation.Regex)) return InputError(step, context, step.Validation.ErrorMessage ?? "Invalid input format.", inputType, ratingMax); // InputType format validation var typeError = ValidateInputType(input, inputType, ratingMax); if (typeError != null) return InputError(step, context, step.Validation?.ErrorMessage ?? typeError, inputType, ratingMax); if (!string.IsNullOrWhiteSpace(step.ContextKey)) context.Variables[step.ContextKey] = originalInput; return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode }; } private static EIPFlowExecutionResultDTO InputError( EIPFlowStepDTO step, EIPExecutionContextDTO context, string message, string inputType, int ratingMax) { return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.StepCode, // stay on this step ResponseMessage = message, InputHint = inputType, RatingMax = ratingMax > 0 ? ratingMax : null }; } private static string? ValidateInputType(string input, string inputType, int ratingMax) { return inputType.ToLowerInvariant() switch { "number" => decimal.TryParse(input, out _) ? null : "Please enter a valid number.", "date" => DateTimeOffset.TryParse(input, out _) ? null : "Please enter a valid date (e.g. 2026-06-20).", "email" => Regex.IsMatch(input, @"^[^@\s]+@[^@\s]+\.[^@\s]+$") ? null : "Please enter a valid email address.", "phone" => Regex.IsMatch(input, @"^[\d\+\-\s\(\)]{6,20}$") ? null : "Please enter a valid phone number.", "rating" => int.TryParse(input, out var r) && r >= 1 && r <= ratingMax ? null : $"Please enter a rating between 1 and {ratingMax}.", _ => null // text, qr_scan, file, location — accept any non-empty string }; } // ========================================================= // TRANSFORM (compute derived values before API call) // ========================================================= private EIPFlowExecutionResultDTO HandleTransform(EIPFlowStepDTO step, EIPExecutionContextDTO context) { var meta = step.Metadata; if (meta == null) return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode }; // Multi-transform mode: Metadata["Transforms"] is a JArray if (meta.TryGetValue("Transforms", out var transformsRaw) && transformsRaw != null) { var transforms = ParseTransformsArray(transformsRaw); foreach (var t in transforms) ApplySingleTransform(t, context); } else { // Single-transform mode: fields directly in Metadata ApplySingleTransform(meta, context); } return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode }; } private void ApplySingleTransform(Dictionary t, EIPExecutionContextDTO context) { var type = GetMetaStr(t, "TransformType") ?? GetMetaStr(t, "Type") ?? string.Empty; var sourceKey = GetMetaStr(t, "SourceKey") ?? string.Empty; var targetKey = GetMetaStr(t, "TargetKey") ?? sourceKey; if (string.IsNullOrWhiteSpace(sourceKey)) return; if (!context.Variables.TryGetValue(sourceKey, out var rawVal)) return; var source = ToPlainString(rawVal); var result = type.ToUpperInvariant() switch { "DATE_FORMAT" => TransformDateFormat(source, t), "VALUE_MAP" => TransformValueMap(source, t), "DATE_DIFF" => TransformDateDiff(source, t, context), _ => source }; if (!string.IsNullOrWhiteSpace(targetKey)) context.Variables[targetKey] = result; } // Converts any stored variable value (including session-restored JsonElement) to a plain string. private static string ToPlainString(object? val) { if (val is null) return string.Empty; if (val is System.Text.Json.JsonElement je) return je.ValueKind == System.Text.Json.JsonValueKind.String ? je.GetString() ?? string.Empty : je.GetRawText(); return val.ToString() ?? string.Empty; } private static string TransformDateFormat(string source, Dictionary t) { var inputFmt = GetMetaStr(t, "InputFormat") ?? "dd-MM-yyyy"; var outputFmt = GetMetaStr(t, "OutputFormat") ?? "ISO8601"; if (!DateTime.TryParseExact(source, inputFmt, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)) return source; return outputFmt.Equals("ISO8601", StringComparison.OrdinalIgnoreCase) ? dt.ToString("yyyy-MM-dd") + "T00:00:00Z" : dt.ToString(outputFmt, CultureInfo.InvariantCulture); } private static string TransformValueMap(string source, Dictionary t) { if (!t.TryGetValue("Map", out var mapRaw) || mapRaw == null) return source; Dictionary? map = null; if (mapRaw is Newtonsoft.Json.Linq.JObject jo) map = jo.ToObject>(); else if (mapRaw is System.Text.Json.JsonElement je && je.ValueKind == System.Text.Json.JsonValueKind.Object) map = System.Text.Json.JsonSerializer.Deserialize>(je.GetRawText()); return map?.TryGetValue(source, out var mapped) == true ? mapped! : source; } // Computes the inclusive number of days between SourceKey date and SecondSourceKey date. // Returns the result as an integer string (e.g. "4") suitable for direct JSON number substitution. // Falls back to "1" on any parse error so the API call is never blocked by a bad date. private static string TransformDateDiff( string startSource, Dictionary t, EIPExecutionContextDTO context) { var inputFmt = GetMetaStr(t, "InputFormat") ?? "dd-MM-yyyy"; var secondKey = GetMetaStr(t, "SecondSourceKey") ?? string.Empty; if (!DateTime.TryParseExact(startSource, inputFmt, CultureInfo.InvariantCulture, DateTimeStyles.None, out var startDate)) return "1"; if (string.IsNullOrWhiteSpace(secondKey) || !context.Variables.TryGetValue(secondKey, out var secondRaw)) return "1"; var endSource = ToPlainString(secondRaw); if (!DateTime.TryParseExact(endSource, inputFmt, CultureInfo.InvariantCulture, DateTimeStyles.None, out var endDate)) return "1"; var days = (int)(endDate.Date - startDate.Date).TotalDays + 1; return (days < 1 ? 1 : days).ToString(); } private static List> ParseTransformsArray(object raw) { if (raw is Newtonsoft.Json.Linq.JArray ja) return ja.ToObject>>() ?? new(); if (raw is System.Text.Json.JsonElement je && je.ValueKind == System.Text.Json.JsonValueKind.Array) return System.Text.Json.JsonSerializer.Deserialize>>(je.GetRawText()) ?? new(); return new(); } private static string? GetMetaStr(Dictionary meta, string key) { if (!meta.TryGetValue(key, out var val)) return null; if (val is Newtonsoft.Json.Linq.JValue jv) return jv.Value?.ToString(); if (val is Newtonsoft.Json.Linq.JToken jt) return jt.ToObject(); if (val is System.Text.Json.JsonElement je) return je.GetString(); return val?.ToString(); } // ========================================================= // CONDITION (legacy — simple contains check on NormalizedMessage) // ========================================================= private EIPFlowExecutionResultDTO HandleCondition( EIPFlowStepDTO step, EIPExecutionContextDTO context) { if (string.IsNullOrWhiteSpace(context.NormalizedMessage)) throw new InvalidOperationException("CONDITION step requires NormalizedMessage."); if (!string.IsNullOrWhiteSpace(step.ConditionExpression) && context.NormalizedMessage.Contains(step.ConditionExpression, StringComparison.OrdinalIgnoreCase)) { return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode }; } throw new InvalidOperationException($"Condition not matched for Step={step.StepCode}"); } // ========================================================= // CONDITIONAL (multi-branch with operators on context values) // ========================================================= private EIPFlowExecutionResultDTO HandleConditional( EIPFlowStepDTO step, EIPExecutionContextDTO context) { var rawValue = ResolveContextPath(step.ConditionKey, context.Variables); foreach (var condition in step.Conditions ?? []) { bool matches = condition.Operator.ToUpperInvariant() switch { "EXISTS" => rawValue != null, "NOT_EXISTS" => rawValue == null, "EQUALS" => string.Equals(rawValue?.ToString(), condition.Value, StringComparison.OrdinalIgnoreCase), "NOT_EQUALS" => !string.Equals(rawValue?.ToString(), condition.Value, StringComparison.OrdinalIgnoreCase), "IN" => condition.Values?.Contains(rawValue?.ToString() ?? string.Empty, StringComparer.OrdinalIgnoreCase) == true, "GT" => double.TryParse(rawValue?.ToString(), out var rv) && double.TryParse(condition.Value, out var cv) && rv > cv, "LT" => double.TryParse(rawValue?.ToString(), out var rv2) && double.TryParse(condition.Value, out var cv2) && rv2 < cv2, _ => false }; if (matches) return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = condition.NextStepCode }; } return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.DefaultNextStepCode }; } // ========================================================= // CHOICE (menu-driven branching) // ========================================================= private EIPFlowExecutionResultDTO HandleChoice(EIPFlowStepDTO step, EIPExecutionContextDTO context) { var reply = context.NormalizedMessage?.Trim() ?? string.Empty; if (!string.IsNullOrWhiteSpace(reply) && step.Choices != null) { var match = step.Choices.FirstOrDefault(c => c.Key.Equals(reply, StringComparison.OrdinalIgnoreCase)); if (match.Key != null) return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = match.Value }; } // First visit or unrecognised reply — present the menu var buttons = step.Choices?.Keys .Select(k => new EIPButtonDTO { Text = k, Payload = k, ButtonText = k }) .ToList(); var isFirstVisit = string.IsNullOrWhiteSpace(reply); var prompt = isFirstVisit ? SubstituteContext(step.MessageTemplate ?? "Please choose an option:", context.Variables) : "Invalid selection. Please choose from the options below:"; return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.StepCode, // stay on this step until valid reply ResponseMessage = prompt, // Only the happy-path prompt maps to step.MessageKey — the "invalid selection" retry text has no template key of its own. MessageKey = isFirstVisit ? step.MessageKey : null, Buttons = buttons }; } // ========================================================= // ACTION (CALL_API | TRIGGER_MESSAGEHUB | DB_UPDATE) // ========================================================= private async Task HandleActionAsync( EIPFlowStepDTO step, EIPExecutionContextDTO context, LoginDTO loginDTO, CancellationToken cancellationToken) { var actionType = step.ActionType?.ToUpperInvariant() ?? string.Empty; switch (actionType) { case "CALL_API": { if (step.ApiConfig == null) throw new InvalidOperationException($"ACTION step '{step.StepCode}' has ActionType=CALL_API but no ApiConfig."); // Always use the local ServiceBaseUrl for loopback calls via eip-internal. // loginDTO.BaseUri is the EXTERNAL gateway URL (may be "192.168.0.112:85" // without a scheme), which produces an invalid combined URL. // ServicePrefix (e.g. "fws") is the YARP routing prefix for external callers; // loopback calls hit the endpoint path directly — no YARP prefix needed. var svcBase = (_configuration["ServiceBaseUrl"] ?? "http://localhost:5000").TrimEnd('/'); var resolvedPath = EIPUrlResolver.SubstituteVars( step.ApiConfig.PathTemplate ?? string.Empty, context.Variables).TrimStart('/'); var url = $"{svcBase}/{resolvedPath}"; _logger.LogInformation( "FLOW | ACTION | CALL_API | Url={Url} | Step={Step}", url, step.StepCode); var client = _httpClientFactory.CreateClient("eip-internal"); HttpResponseMessage response; var method = step.ApiConfig.Method; if (method.Equals("POST", StringComparison.OrdinalIgnoreCase) || method.Equals("PUT", StringComparison.OrdinalIgnoreCase)) { // Parameters takes priority; fall back to BodyTemplate substitution string bodyStr; Dictionary extraHeaders; if (step.ApiConfig.Parameters?.Count > 0) { (bodyStr, extraHeaders) = _urlResolver.BuildRequest(step.ApiConfig, context.Variables); } else { bodyStr = step.ApiConfig.BodyTemplate != null ? SubstituteContext(step.ApiConfig.BodyTemplate, context.Variables) : string.Empty; extraHeaders = new Dictionary(); } using var content = new StringContent(bodyStr, Encoding.UTF8, "application/json"); using var req = new HttpRequestMessage( method.Equals("PUT", StringComparison.OrdinalIgnoreCase) ? HttpMethod.Put : HttpMethod.Post, url) { Content = content }; foreach (var h in extraHeaders) req.Headers.TryAddWithoutValidation(h.Key, h.Value); if (step.ApiConfig.Headers != null) foreach (var h in step.ApiConfig.Headers) req.Headers.TryAddWithoutValidation(h.Key, h.Value); if (loginDTO is not null) req.Headers.TryAddWithoutValidation("Login", JsonSerializer.Serialize(loginDTO)); response = await client.SendAsync(req, cancellationToken).ConfigureAwait(false); } else { using var req = new HttpRequestMessage(HttpMethod.Get, url); if (step.ApiConfig.Headers != null) foreach (var h in step.ApiConfig.Headers) req.Headers.TryAddWithoutValidation(h.Key, h.Value); if (loginDTO is not null) req.Headers.TryAddWithoutValidation("Login", JsonSerializer.Serialize(loginDTO)); response = await client.SendAsync(req, cancellationToken).ConfigureAwait(false); } response.EnsureSuccessStatusCode(); var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(step.ContextKey)) context.Variables[step.ContextKey] = responseBody; _logger.LogInformation( "FLOW | ACTION | CALL_API | OK | ContextKey={Key} | Step={Step}", step.ContextKey, step.StepCode); // Build structured data when ResponseFormat is declared and type is not plain TEXT EIPStructuredDataDTO? structuredData = null; if (step.ResponseFormat != null && !string.Equals(step.ResponseFormat.Type, "TEXT", StringComparison.OrdinalIgnoreCase)) { structuredData = BuildStructuredData(responseBody, step.ResponseFormat, _logger); } return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode, ResponseMessage = string.IsNullOrWhiteSpace(step.MessageTemplate) ? null : SubstituteContext(step.MessageTemplate, context.Variables), StructuredData = structuredData }; } case "TRIGGER_MESSAGEHUB": { _logger.LogInformation( "FLOW | ACTION | TRIGGER_MESSAGEHUB | EntityId={Id} | Step={Step}", step.EntityId, step.StepCode); await _messageHubGeneratorBLL.GenerateAsync(step.EntityId, loginDTO, cancellationToken) .ConfigureAwait(false); break; } case "DB_UPDATE": { if (string.IsNullOrWhiteSpace(step.CapabilityCode)) throw new InvalidOperationException($"ACTION step '{step.StepCode}' has ActionType=DB_UPDATE but no CapabilityCode."); _logger.LogInformation( "FLOW | ACTION | DB_UPDATE | CapabilityCode={Code} | Step={Step}", step.CapabilityCode, step.StepCode); await _capabilityEngine.ExecuteCapabilityAsync(step.CapabilityCode, context, loginDTO, cancellationToken) .ConfigureAwait(false); break; } default: throw new InvalidOperationException($"Unknown ActionType '{step.ActionType}' for step '{step.StepCode}'."); } return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode, ResponseMessage = string.IsNullOrWhiteSpace(step.MessageTemplate) ? null : SubstituteContext(step.MessageTemplate, context.Variables) }; } // ========================================================= // CAPABILITY // ========================================================= private async Task HandleCapabilityAsync( EIPFlowStepDTO step, EIPExecutionContextDTO context, LoginDTO loginDTO, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(step.CapabilityCode)) throw new InvalidOperationException($"CapabilityCode missing for Step={step.StepCode}"); var capabilityResult = await _capabilityEngine.ExecuteCapabilityAsync( step.CapabilityCode, context, loginDTO, cancellationToken).ConfigureAwait(false); // OTP prompt (or a wrong-OTP retry prompt) — loop back to this same step so the // next inbound message resumes here instead of racing past to NextStepCode. // ShouldPauseExecution below detects this via NextStepCode == step.StepCode. if (capabilityResult.RequiresPause) { return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.StepCode, ResponseMessage = capabilityResult.Message }; } // When ContextKey is set the result is stored in context for downstream steps // (e.g. FETCH_BALANCE stores balance text; SHOW_BALANCE MESSAGE step renders {BalanceSummary}). // Return empty ResponseMessage so the flow continues without pausing here. if (!string.IsNullOrWhiteSpace(step.ContextKey)) { context.Variables[step.ContextKey] = capabilityResult.Message ?? string.Empty; return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode, ResponseMessage = string.Empty }; } var usedStaticTemplate = string.IsNullOrWhiteSpace(capabilityResult.Message); var finalMessage = usedStaticTemplate ? SubstituteContext(step.MessageTemplate ?? string.Empty, context.Variables) : capabilityResult.Message; return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode, ResponseMessage = finalMessage, // Only when the step's own static template rendered — a dynamic capabilityResult.Message // (e.g. a balance figure) has no MEIPMESSAGETEMPLATE key of its own. MessageKey = usedStaticTemplate ? step.MessageKey : null }; } // ========================================================= // EXTERNAL ACTION (Mode 1 — button press decision map) // ========================================================= private EIPFlowExecutionResultDTO HandleExternalAction( EIPFlowStepDTO step, EIPExecutionContextDTO context) { var decision = context.NormalizedMessage?.Trim().ToUpperInvariant(); if (string.IsNullOrWhiteSpace(decision)) return new EIPFlowExecutionResultDTO { IsCompleted = false, ResponseMessage = step.MessageTemplate }; if (step.DecisionMap == null || !step.DecisionMap.TryGetValue(decision, out var nextStepObj)) return new EIPFlowExecutionResultDTO { IsCompleted = false, ResponseMessage = "Invalid choice." }; return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = nextStepObj?.ToString() }; } // ========================================================= // END // ========================================================= private EIPFlowExecutionResultDTO HandleEnd(EIPFlowStepDTO step) { return new EIPFlowExecutionResultDTO { IsCompleted = true, NextStepCode = null, ResponseMessage = step.MessageTemplate ?? string.Empty }; } // ========================================================= // Post-step side-effect actions list (future extensibility) // ========================================================= private Task ExecuteActionsAsync( EIPFlowStepDTO step, EIPExecutionContextDTO context, CancellationToken cancellationToken) { if (step.Actions == null || step.Actions.Count == 0) return Task.CompletedTask; foreach (var action in step.Actions) _logger.LogInformation("FLOW | SIDE-EFFECT | Type={Type} | Step={Step}", action.ActionType, step.StepCode); return Task.CompletedTask; } // ========================================================= // Helpers // ========================================================= /// /// Replaces {Key} and {Key.SubKey} tokens in a template string with values from the context /// Variables dict. For dot-notation, the root value is parsed as a JSON string and SubKey is /// extracted from the resulting JSON object (one level only). /// private static string SubstituteContext(string template, Dictionary variables) { if (string.IsNullOrEmpty(template) || variables.Count == 0) return template; return Regex.Replace(template, @"\{([A-Za-z][A-Za-z0-9_]*)\}", m => { var token = m.Groups[1].Value; var dotIdx = token.IndexOf('.', StringComparison.Ordinal); if (dotIdx < 0) { // Flat key — direct lookup if (!variables.TryGetValue(token, out var val)) return m.Value; if (val is JsonElement flatJe) return flatJe.ValueKind == JsonValueKind.String ? flatJe.GetString() ?? string.Empty : flatJe.GetRawText(); return val?.ToString() ?? string.Empty; } // Dot-notation: {VarKey.SubKey} — parse VarKey as JSON and extract SubKey var varKey = token[..dotIdx]; var subKey = token[(dotIdx + 1)..]; if (!variables.TryGetValue(varKey, out var rootObj) || rootObj is null) return m.Value; var jsonStr = rootObj.ToString(); if (string.IsNullOrWhiteSpace(jsonStr)) return m.Value; try { using var doc = JsonDocument.Parse(jsonStr); var root = doc.RootElement; if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(subKey, out var subEl)) { return subEl.ValueKind == JsonValueKind.String ? subEl.GetString() ?? string.Empty : subEl.ToString(); } return m.Value; } catch { return m.Value; } }); } /// /// Resolves a context key (supports simple dotted paths like "Balance.Days"). /// Returns null when the key or sub-property is not found. /// private static object? ResolveContextPath(string? keyPath, Dictionary variables) { if (string.IsNullOrWhiteSpace(keyPath)) return null; var parts = keyPath.Split('.', 2); if (!variables.TryGetValue(parts[0], out var root)) return null; if (parts.Length == 1) return root; // One level of nesting via System.Text.Json or dictionary if (root is JsonElement je) { return je.TryGetProperty(parts[1], out var sub) ? (object)(sub.ValueKind == JsonValueKind.String ? sub.GetString()! : sub.ToString()) : null; } if (root is Dictionary nested) { return nested.TryGetValue(parts[1], out var nv) ? nv : null; } // CALL_API stores the raw response body as a JSON string — parse and navigate it. if (root is string rawJson && rawJson.TrimStart().StartsWith("{", StringComparison.Ordinal)) { try { using var doc = System.Text.Json.JsonDocument.Parse(rawJson); return doc.RootElement.TryGetProperty(parts[1], out var sub) ? (object)(sub.ValueKind == JsonValueKind.String ? sub.GetString()! : sub.ToString()) : null; } catch { return null; } } return null; } // ========================================================= // PRESENT — display previously stored API response // ========================================================= private EIPFlowExecutionResultDTO HandlePresent(EIPFlowStepDTO step, EIPExecutionContextDTO context) { EIPStructuredDataDTO? structuredData = null; if (step.ResponseFormat != null && !string.Equals(step.ResponseFormat.Type, "TEXT", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(step.ContextKey) && context.Variables.TryGetValue(step.ContextKey, out var stored) && stored is not null) { structuredData = BuildStructuredData(stored.ToString() ?? string.Empty, step.ResponseFormat, _logger); } return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode, ResponseMessage = string.IsNullOrWhiteSpace(step.MessageTemplate) ? null : SubstituteContext(step.MessageTemplate, context.Variables), StructuredData = structuredData }; } // ========================================================= // BuildStructuredData — parse JSON response into typed rows // ========================================================= private static EIPStructuredDataDTO? BuildStructuredData( string responseBody, EIPResponseFormatDTO format, ILogger logger) { if (string.IsNullOrWhiteSpace(responseBody)) return null; try { using var doc = JsonDocument.Parse(responseBody); var dataEl = NavigateJsonPath(doc.RootElement, format.DataPath); // Normalise to an array of objects List elements; if (dataEl.ValueKind == JsonValueKind.Array) { elements = dataEl.EnumerateArray().ToList(); } else if (dataEl.ValueKind == JsonValueKind.Object) { elements = new List { dataEl }; } else { // Scalar value — wrap as a single "Value" row var result = new EIPStructuredDataDTO { Type = format.Type, Fields = new List { new() { Field = "Value", Label = "Value" } }, Rows = new List> { new() { ["Value"] = dataEl.ToString() } }, TotalCount = 1 }; return result; } var totalCount = elements.Count; // Auto-derive fields from first element when not declared List fields; if (format.Fields is { Count: > 0 }) { fields = format.Fields; } else if (elements.Count > 0 && elements[0].ValueKind == JsonValueKind.Object) { fields = elements[0].EnumerateObject() .Take(8) .Select(p => new EIPFieldDefinitionDTO { Field = p.Name, Label = p.Name }) .ToList(); } else { fields = new List(); } // Apply MaxRows var limited = elements.Take(format.MaxRows).ToList(); var rows = limited.Select(el => { var row = new Dictionary(StringComparer.OrdinalIgnoreCase); if (el.ValueKind == JsonValueKind.Object) { foreach (var field in fields) { if (el.TryGetProperty(field.Field, out var prop)) row[field.Field] = prop.ValueKind == JsonValueKind.String ? prop.GetString() ?? string.Empty : prop.ToString(); else row[field.Field] = string.Empty; } } return row; }).ToList(); return new EIPStructuredDataDTO { Type = format.Type, Fields = fields, Rows = rows, TotalCount = totalCount, IsTruncated = rows.Count < totalCount }; } catch (Exception ex) { logger.LogWarning(ex, "BuildStructuredData: failed to parse response body"); return null; } } // ========================================================= // NavigateJsonPath — traverse dot-separated path into JSON // ========================================================= private static JsonElement NavigateJsonPath(JsonElement root, string? path) { if (string.IsNullOrWhiteSpace(path)) return root; var current = root; foreach (var segment in path.Split('.')) { if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next)) return root; // path invalid — fall back to root current = next; } return current; } // ========================================================= // QUALIFY — GOP Qualifier + optional Mapper // ========================================================= private async Task HandleQualifyAsync( EIPFlowStepDTO step, EIPExecutionContextDTO context, LoginDTO loginDTO, CancellationToken ct) { var cfg = step.QualifyConfig; if (cfg == null) throw new InvalidOperationException($"QUALIFY step '{step.StepCode}' has no QualifyConfig."); // Build payload JSON from selected context variables var payloadDict = cfg.InputKeys.Count > 0 ? cfg.InputKeys .Where(k => context.Variables.ContainsKey(k)) .ToDictionary(k => k, k => context.Variables[k]) : context.Variables.ToDictionary(kv => kv.Key, kv => kv.Value); var payloadJson = JsonSerializer.Serialize(payloadDict); // Resolve entity ID from context int entityId = 0; if (!string.IsNullOrWhiteSpace(cfg.BoundToIdContextKey) && context.Variables.TryGetValue(cfg.BoundToIdContextKey, out var idObj)) _ = int.TryParse(idObj?.ToString(), out entityId); var qCtx = new QualifierExecutionContext { ClientId = loginDTO.ClientId, EntityId = entityId, EntityCode = cfg.BoundToType, Stage = cfg.Stage, Scope = cfg.Scope, Document = JsonDocument.Parse(payloadJson), CorrelationId = context.CorrelationId.ToString(), CancellationToken = ct }; _logger.LogInformation( "FLOW | QUALIFY | Entity={Entity} | Stage={Stage} | Step={Step}", cfg.BoundToType, cfg.Stage, step.StepCode); var result = await _qualifierFacade.ExecuteStageAsync(qCtx, loginDTO, ct).ConfigureAwait(false); // Store facts if (!string.IsNullOrWhiteSpace(cfg.OutputFactKey)) { var factsJson = JsonSerializer.Serialize(result.Facts); context.Variables[cfg.OutputFactKey] = factsJson; } // Store first error for template use context.Variables["_QualifyError"] = result.HasErrors ? result.Findings.First(f => f.Severity == FindingSeverity.Error).Message : string.Empty; // Route to error step if errors found and error step configured if (result.HasErrors && !string.IsNullOrWhiteSpace(cfg.OnValidationErrorStepCode)) { _logger.LogInformation( "FLOW | QUALIFY | Errors found, routing to {Step}", cfg.OnValidationErrorStepCode); return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = cfg.OnValidationErrorStepCode, ResponseMessage = string.IsNullOrWhiteSpace(step.MessageTemplate) ? null : SubstituteContext(step.MessageTemplate, context.Variables) }; } // Optional mapper if (!string.IsNullOrWhiteSpace(cfg.MapperCode) && result.Facts != null) { var factsJson = JsonSerializer.Serialize(result.Facts); var mapped = await _mappingEngine .TransformAsync(cfg.MapperCode, factsJson, loginDTO, ct) .ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(cfg.OutputMapKey)) context.Variables[cfg.OutputMapKey] = mapped; } return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode, ResponseMessage = string.IsNullOrWhiteSpace(step.MessageTemplate) ? null : SubstituteContext(step.MessageTemplate, context.Variables) }; } // ========================================================= // ENRICH — GOP Mapper only // ========================================================= private async Task HandleEnrichAsync( EIPFlowStepDTO step, EIPExecutionContextDTO context, LoginDTO loginDTO, CancellationToken ct) { var cfg = step.EnrichConfig; if (cfg == null) throw new InvalidOperationException($"ENRICH step '{step.StepCode}' has no EnrichConfig."); var sourceJson = context.Variables.TryGetValue(cfg.InputKey, out var src) ? src?.ToString() ?? "{}" : "{}"; _logger.LogInformation( "FLOW | ENRICH | MapperCode={Code} | InputKey={Key} | Step={Step}", cfg.MapperCode, cfg.InputKey, step.StepCode); var mapped = await _mappingEngine .TransformAsync(cfg.MapperCode, sourceJson, loginDTO, ct) .ConfigureAwait(false); context.Variables[cfg.OutputKey] = mapped; return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode, ResponseMessage = string.IsNullOrWhiteSpace(step.MessageTemplate) ? null : SubstituteContext(step.MessageTemplate, context.Variables) }; } // ========================================================= // DYNAMIC_CHOICE — interactive selection from stored API result // ========================================================= private async Task HandleDynamicChoiceAsync( EIPFlowStepDTO step, EIPExecutionContextDTO context, LoginDTO loginDTO, CancellationToken ct) { await Task.CompletedTask.ConfigureAwait(false); // async contract var cfg = step.DynamicChoiceConfig; if (cfg == null) throw new InvalidOperationException($"DYNAMIC_CHOICE step '{step.StepCode}' has no DynamicChoiceConfig."); if (!context.Variables.TryGetValue(cfg.SourceKey, out var sourceObj) || sourceObj is null) throw new InvalidOperationException( $"DYNAMIC_CHOICE step '{step.StepCode}': SourceKey '{cfg.SourceKey}' not found in context."); var sourceJson = sourceObj.ToString() ?? "[]"; using var doc = JsonDocument.Parse(sourceJson); var dataEl = NavigateJsonPath(doc.RootElement, cfg.DataPath); List items; if (dataEl.ValueKind == JsonValueKind.Array) items = dataEl.EnumerateArray().ToList(); else if (dataEl.ValueKind == JsonValueKind.Object) items = new List { dataEl }; else items = new List(); var reply = context.NormalizedMessage?.Trim() ?? string.Empty; // Check if reply matches any item's label or value if (!string.IsNullOrWhiteSpace(reply)) { var matched = items.FirstOrDefault(el => { if (el.ValueKind != JsonValueKind.Object) return false; var label = el.TryGetProperty(cfg.LabelField, out var lp) ? (lp.ValueKind == JsonValueKind.String ? lp.GetString() : lp.ToString()) : null; var value = el.TryGetProperty(cfg.ValueField, out var vp) ? (vp.ValueKind == JsonValueKind.String ? vp.GetString() : vp.ToString()) : null; return string.Equals(reply, label, StringComparison.OrdinalIgnoreCase) || string.Equals(reply, value, StringComparison.OrdinalIgnoreCase); }); if (matched.ValueKind != JsonValueKind.Undefined) { // Store selected value if (!string.IsNullOrWhiteSpace(cfg.StoreSelectedKey)) { var val = matched.TryGetProperty(cfg.ValueField, out var vp) ? (vp.ValueKind == JsonValueKind.String ? vp.GetString() : vp.ToString()) : reply; context.Variables[cfg.StoreSelectedKey] = val ?? reply; } // Store entire selected item as JSON string for dot-notation access if (!string.IsNullOrWhiteSpace(cfg.StoreItemKey)) context.Variables[cfg.StoreItemKey] = matched.ToString(); _logger.LogInformation( "FLOW | DYNAMIC_CHOICE | Selected={Reply} | Step={Step}", reply, step.StepCode); return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode }; } } // First visit or invalid reply — build buttons var buttons = items .Take(cfg.MaxChoices) .Select(el => { var label = el.ValueKind == JsonValueKind.Object && el.TryGetProperty(cfg.LabelField, out var lp) ? (lp.ValueKind == JsonValueKind.String ? lp.GetString() ?? string.Empty : lp.ToString()) : el.ToString(); var value = el.ValueKind == JsonValueKind.Object && el.TryGetProperty(cfg.ValueField, out var vp) ? (vp.ValueKind == JsonValueKind.String ? vp.GetString() ?? string.Empty : vp.ToString()) : label; return new EIPButtonDTO { Text = label, Payload = value, ButtonText = label, ButtonType = "REPLY" }; }) .ToList(); var prompt = string.IsNullOrWhiteSpace(step.MessageTemplate) ? "Please select an option:" : SubstituteContext(step.MessageTemplate, context.Variables); return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.StepCode, // stay until valid selection ResponseMessage = prompt, Buttons = buttons }; } // ========================================================= // LOCATION — wait for user to share GPS location // ========================================================= private EIPFlowExecutionResultDTO HandleLocation(EIPFlowStepDTO step, EIPExecutionContextDTO context) { // Check if location was received in this turn (stored at session start) if (context.Variables.TryGetValue("_Location.Lat", out var lat) && lat is not null && !string.IsNullOrWhiteSpace(lat.ToString())) { // Store combined location JSON into ContextKey for downstream use if (!string.IsNullOrWhiteSpace(step.ContextKey)) { context.Variables.TryGetValue("_Location.Lng", out var lng); context.Variables.TryGetValue("_Location.Address", out var addr); var locJson = JsonSerializer.Serialize(new { Lat = lat.ToString(), Lng = lng?.ToString() ?? string.Empty, Address = addr?.ToString() ?? string.Empty }); context.Variables[step.ContextKey] = locJson; } return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode }; } // Location not yet received — ask user to share return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.StepCode, // stay until location arrives ResponseMessage = SubstituteContext( step.MessageTemplate ?? "Please share your current location.", context.Variables), RequestLocation = true, InputHint = "location" }; } // ========================================================= // LINK — send URL buttons without expecting a conversation reply // ========================================================= private EIPFlowExecutionResultDTO HandleLink(EIPFlowStepDTO step, EIPExecutionContextDTO context) { var buttons = step.LinkButtons? .Select(lb => new EIPButtonDTO { Title = lb.Title, Url = SubstituteContext(lb.Url, context.Variables), ButtonType = "URL" }) .ToList() ?? new List(); return new EIPFlowExecutionResultDTO { IsCompleted = false, NextStepCode = step.NextStepCode, ResponseMessage = string.IsNullOrWhiteSpace(step.MessageTemplate) ? null : SubstituteContext(step.MessageTemplate, context.Variables), Buttons = buttons }; } } }