using Dapr.Client; using FrameworkDAL.CustomCode.WorkFlow; using FrameworkDAL.DTO.Workflow; using GB5Shared.WorkFlow; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.PubSub; using GB5Shared.DTO.WorkFlow; using GB5Shared.GenerateAutoNumber; using GB5Shared.PubSub.OutBox; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.WorkFlow.Wip; using GB5Shared.WorkFlow.WorkFlowEngine; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using static GB5Shared.DTO.WorkFlow.ContractsDTO; using static GB5Shared.GB5Constant.Constant; using JsonSerializer = System.Text.Json.JsonSerializer; using WorkflowDTO = FrameworkDAL.DTO.Workflow.WorkflowDTO; namespace FrameworkBLL.WorkFlow { public class WorkFlowBLL : IWorkFlowBLL { private readonly IWorkFlowDAL _WorkFlowDAL; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly IWipApprovalDispatcher _wipDispatcher; private readonly IOutBox _outBox; private readonly DaprClient _dapr; private readonly ILogger _logger; // Compact serialiser — nulls omitted, PascalCase preserved. // Matches EventHandler.cs _publishSerialiserOptions exactly. private static readonly JsonSerializerOptions _publishSerialiserOptions = new() { WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public WorkFlowBLL( IWorkFlowDAL WorkFlowDAL, AutoNumber AutoNumber, IQueryExecutor QueryExecutor, IWipApprovalDispatcher wipDispatcher, IOutBox outBox, DaprClient dapr, ILogger logger) { _WorkFlowDAL = WorkFlowDAL; _AutoNumber = AutoNumber; _QueryExecutor = QueryExecutor; _wipDispatcher = wipDispatcher; _outBox = outBox; _dapr = dapr; _logger = logger; } // ── Workflow engine ─────────────────────────────────────────────────── public async Task CheckApplicability( WorkflowCheckContext context, LoginDTO loginDTO) { try { return await _WorkFlowDAL.CheckApplicability(context, loginDTO); } catch (WorkflowEngineException) { throw; } catch (Exception ex) { throw new WorkflowEngineException("CheckApplicability failed: " + ex.Message, ex); } } public async Task StartWorkflow( WorkflowStartRequest request, LoginDTO loginDTO) { try { var result = await _WorkFlowDAL.StartWorkflow(request, loginDTO); return result; } catch (WorkflowEngineException) { throw; } catch (Exception ex) { throw new WorkflowEngineException("StartWorkflow failed: " + ex.Message, ex); } } // ── Actions ─────────────────────────────────────────────────────────── public async Task WorkFlowActions(WorkFlowActionDTO WorkFlowActionDTO, LoginDTO LoginDTO) { // ── Top-level span — covers the full action processing pipeline ─── using var actionActivity = GB5ActivitySources.WorkflowAction.StartActivity( "workflow.action", ActivityKind.Internal); actionActivity?.SetTag("gb5.tenant.id", LoginDTO.ClientId); actionActivity?.SetTag("gb5.user.id", LoginDTO.UserId); actionActivity?.SetTag("gb5.ou.id", LoginDTO.WorkOUId); actionActivity?.SetTag("gb5.workflow.action_count", WorkFlowActionDTO?.Items?.Count ?? 0); try { // DAL commits the transaction and returns action result var result = await _WorkFlowDAL.Actions(WorkFlowActionDTO, LoginDTO); actionActivity?.AddEvent(new ActivityEvent("workflow.action.dal.done")); actionActivity?.SetTag("gb5.workflow.pending_dispatches", result.PendingDispatches?.Count ?? 0); actionActivity?.SetTag("gb5.workflow.outcome_count", result.Outcomes?.Count ?? 0); // ── Dispatch pending WIP callbacks AFTER the transaction commits ── // Each dispatch calls the original entity save API with STATUS=1. if (result.PendingDispatches?.Count > 0) { foreach (var dispatch in result.PendingDispatches) { await _wipDispatcher.DispatchAsync(dispatch, LoginDTO); } } actionActivity?.AddEvent(new ActivityEvent("workflow.action.wip_dispatch.done")); // ── Publish outcome events AFTER the transaction commits ────────── // One event per terminal action (Approve-final / Reject / Return). // Mirrors the EventHandler.cs dual-publish pattern exactly: // Path A — direct Dapr publish (fire-and-forget, immediate delivery) // Path B — outbox write (guaranteed at-least-once via background job) if (result.Outcomes?.Count > 0) { var loginJson = BuildWorkflowLoginJson(LoginDTO); foreach (var outcome in result.Outcomes) { int eventTypeId = outcome.Action switch { (int)WorkflowAction.Approve => EventTypeConstant.APPROVEWORKFLOW, (int)WorkflowAction.Reject => EventTypeConstant.REJECTWORKFLOW, (int)WorkflowAction.Return => EventTypeConstant.RESENDWORKFLOW, _ => 0 }; if (eventTypeId == 0) continue; // Serialise entity snapshot exactly like WorkflowTaskNotificationRequest var entityPayload = outcome.DataJson is string s ? s : (outcome.DataJson != null ? JsonSerializer.Serialize(outcome.DataJson) : "{}"); var payloadObj = new { WorkflowInstanceId = outcome.WorkflowInstanceId, WorkflowId = outcome.WorkflowId, EntityId = outcome.EntityId, ObjectId = outcome.ObjectId, Action = outcome.Action, ActionByUserId = outcome.ActionByUserId, Comment = outcome.Comment, EntityPayload = entityPayload, FactsJson = outcome.FactsJson }; var payloadJson = JsonSerializer.Serialize(payloadObj); var contextJson = BuildWorkflowContextJson(outcome, eventTypeId, LoginDTO); var topic = $"EVENTTYPEID:{eventTypeId}"; // Use DatabaseName as fallback when ConnectionDatabaseName is empty // so EventSubBLL always gets a valid connection without a MSERVERCONFIG lookup. var connectionName = !string.IsNullOrWhiteSpace(LoginDTO.ConnectionDatabaseName) ? LoginDTO.ConnectionDatabaseName : (LoginDTO.DatabaseName ?? string.Empty); // ── Path A: direct Dapr publish ─────────────────────── // If this succeeds, Path B (outbox) is skipped — no duplicates. // If this fails, Path B writes to TOUTBOX so the Quartz job retries. bool pathAPublished = false; using (var publishActivity = GB5ActivitySources.WorkflowAction.StartActivity( "workflow.action.direct_publish", ActivityKind.Producer)) { // ── Routing / identity ──────────────────────────────── publishActivity?.SetTag("gb5.msg.pubsub", "pubsub"); publishActivity?.SetTag("gb5.msg.topic", topic); publishActivity?.SetTag("gb5.msg.event_type_id", eventTypeId); publishActivity?.SetTag("gb5.msg.object_type_id", outcome.EntityId); publishActivity?.SetTag("gb5.msg.object_id", outcome.ObjectId); publishActivity?.SetTag("gb5.msg.tenant_id", LoginDTO.ClientId); publishActivity?.SetTag("gb5.msg.user_id", LoginDTO.UserId); publishActivity?.SetTag("gb5.msg.ou_id", LoginDTO.WorkOUId); publishActivity?.SetTag("gb5.msg.connection_name", connectionName); publishActivity?.SetTag("gb5.workflow.instance_id", outcome.WorkflowInstanceId); publishActivity?.SetTag("gb5.workflow.id", outcome.WorkflowId); publishActivity?.SetTag("gb5.workflow.action", outcome.Action); publishActivity?.SetTag("gb5.workflow.action_by", outcome.ActionByUserId); // ── Full published data — visible in Zipkin / Jaeger detail ─ publishActivity?.SetTag("gb5.msg.payload", payloadJson); publishActivity?.SetTag("gb5.msg.login_json", loginJson); publishActivity?.SetTag("gb5.msg.context_json", contextJson); try { await _dapr.PublishEventAsync( "pubsub", topic, new OutboxEventMessage { EventTypeId = eventTypeId, ObjectTypeId = outcome.EntityId, ObjectId = outcome.ObjectId, TenantId = LoginDTO.ClientId, UserId = LoginDTO.UserId, Payload = payloadJson, ConnectionName = connectionName, LoginJson = loginJson, ContextJson = contextJson }, cancellationToken: System.Threading.CancellationToken.None) .ConfigureAwait(false); pathAPublished = true; publishActivity?.SetStatus(ActivityStatusCode.Ok); publishActivity?.SetTag("gb5.dapr.direct_publish_status", "success"); } catch (Exception pubEx) { publishActivity?.AddException(pubEx); publishActivity?.SetStatus(ActivityStatusCode.Error, pubEx.Message); publishActivity?.SetTag("gb5.dapr.direct_publish_status", "failed"); publishActivity?.SetTag("gb5.dapr.direct_publish_error", pubEx.GetType().Name); _logger.LogWarning(pubEx, "Dapr direct publish failed for workflow outcome | " + "EventTypeId={EventTypeId} WorkflowInstanceId={InstanceId} " + "ObjectId={ObjectId} Action={Action}", eventTypeId, outcome.WorkflowInstanceId, outcome.ObjectId, outcome.Action); } } // ── Path B: outbox fallback (only when Path A failed) ───── // Prevents duplicate events — Path B fires only if Dapr is unavailable. if (!pathAPublished) try { await _outBox.PublishEventAsync(new OutboxDTO { EventTypeId = eventTypeId, ObjectTypeId = outcome.EntityId, ObjectId = outcome.ObjectId, Version = 1, Payload = payloadObj, //ContextJson = contextJson }, LoginDTO, null!); } catch (Exception outboxEx) { // Log but do not fail — the workflow transaction is already committed. _logger.LogError(outboxEx, "Outbox write failed for workflow outcome | " + "EventTypeId={EventTypeId} WorkflowInstanceId={InstanceId} " + "ObjectId={ObjectId} Action={Action}", eventTypeId, outcome.WorkflowInstanceId, outcome.ObjectId, outcome.Action); } } } actionActivity?.AddEvent(new ActivityEvent("workflow.action.outcome_publish.done")); actionActivity?.SetStatus(ActivityStatusCode.Ok); return await BuildActionResponseAsync(result, LoginDTO); } catch (Exception ex) { actionActivity?.AddException(ex); actionActivity?.SetStatus(ActivityStatusCode.Error, ex.Message); actionActivity?.SetTag("gb5.workflow.action_error_type", ex.GetType().Name); actionActivity?.SetTag("gb5.workflow.action_error_message", ex.Message); throw; } } // ── Action response message building ──────────────────────────────── // // Builds the human-readable success message the caller displays after an // approve/reject/resend action, e.g. "Casual Leave Request Approved // Successfully." for a single item, or "3 Requests Approved Successfully." // for a batch. Entity type / sub-type are resolved from // WorkflowActionResult.ProcessedItems, which — unlike Outcomes — carries one // entry per action processed regardless of whether it was terminal for the // workflow, so every task the caller just acted on gets a message. private async Task BuildActionResponseAsync( WorkflowActionResult result, LoginDTO loginDTO) { var response = new WorkFlowActionResponseDTO { TotalCount = result.ProcessedItems.Count }; foreach (var processedItem in result.ProcessedItems) { var (entityType, subType) = await ResolveEntityDisplayAsync(processedItem, loginDTO); response.Items.Add(new WorkFlowActionResultItemDTO { TaskId = processedItem.TaskId, Action = processedItem.Action, EntityId = processedItem.EntityId, EntityType = entityType, SubType = subType }); } if (response.Items.Count == 0) { // Defensive fallback — every processed action populates ProcessedItems, // so this should not happen in practice. response.Message = "Action processed successfully."; } else if (response.Items.Count == 1) { var only = response.Items[0]; response.Message = BuildSingleItemMessage(only.EntityType, only.SubType, only.Action); } else { // Batch actions apply a single action to every selected item. response.Message = BuildMultipleItemsMessage(response.Items.Count, response.Items[0].Action); } return response; } /// /// Resolves the friendly entity type + optional sub-type (Leave Type / Timeslip /// Type) for a single processed workflow item. Leave, Timeslip and Punch are /// hardcoded (their EntityConstant values and DataJson shapes are known); any /// other entity — including Service Request once it is wired into this engine — /// falls back to a live MENTITY.ENTITYNAME lookup so new entity types get a /// reasonable message without code changes here. /// private async Task<(string EntityType, string? SubType)> ResolveEntityDisplayAsync( WorkflowActionItemInfo item, LoginDTO loginDTO) { if (item.EntityId == EntityConstant.TLEAVE) return ("Leave", ExtractLeaveTypeName(item.DataJson)); if (item.EntityId == EntityConstant.OBJECTTIMESLIP) return ("Timeslip", ExtractTimeslipTypeName(item.DataJson)); if (item.EntityId == EntityConstant.PUNCH) return ("Punch", null); var entityName = await _WorkFlowDAL.GetEntityNameAsync(item.EntityId, loginDTO); return (string.IsNullOrWhiteSpace(entityName) ? "Request" : entityName, null); } private static string BuildSingleItemMessage(string entityType, string? subType, int action) { string verb = ActionVerb(action); return entityType switch { "Leave" => $"{subType ?? "Leave"} Request {verb} Successfully.", "Timeslip" => $"{subType ?? "Timeslip"} Request {verb} Successfully.", "Punch" => $"Missed Punch Request {verb} Successfully.", _ => subType != null ? $"{subType} Request {verb} Successfully." : $"{entityType} {verb} Successfully." }; } private static string BuildMultipleItemsMessage(int count, int action) => $"{count} Requests {ActionVerb(action)} Successfully."; private static string ActionVerb(int action) => action switch { (int)WorkflowAction.Approve => "Approved", (int)WorkflowAction.Reject => "Rejected", (int)WorkflowAction.Return => "Resent", _ => "Processed" }; /// /// DataJson snapshot of a TLeaveDTO save: the leave type name lives on the first /// detail line (TLeaveDetailArray[0].LeaveTypeName), resolved from MLEAVE at save /// time. Falls back to a root-level LeaveTypeName in case of a differently /// shaped snapshot. /// private static string? ExtractLeaveTypeName(object? dataJson) { var root = ParseToElement(dataJson); if (root is null) return null; var el = root.Value; if (el.TryGetProperty("TLeaveDetailArray", out var details) && details.ValueKind == JsonValueKind.Array) { foreach (var detail in details.EnumerateArray()) { if (detail.TryGetProperty("LeaveTypeName", out var detailName) && detailName.ValueKind == JsonValueKind.String) { return detailName.GetString(); } break; // only the first detail line carries the request's leave type } } if (el.TryGetProperty("LeaveTypeName", out var rootName) && rootName.ValueKind == JsonValueKind.String) { return rootName.GetString(); } return null; } /// /// DataJson snapshot of a TimeSlipDTO save: the "Timeslip Type" the requirement /// refers to (e.g. "Late Coming", "Comp Off") is the BizTransactionType, not the /// Official/Personal TimeSlipType flag. /// private static string? ExtractTimeslipTypeName(object? dataJson) { var root = ParseToElement(dataJson); if (root is null) return null; var el = root.Value; if (el.TryGetProperty("BizTransactionTypeName", out var name) && name.ValueKind == JsonValueKind.String) { return name.GetString(); } return null; } private static JsonElement? ParseToElement(object? dataJson) { if (dataJson is null) return null; try { string json = dataJson switch { string s => s, JsonElement je => je.GetRawText(), _ => JsonSerializer.Serialize(dataJson) }; if (string.IsNullOrWhiteSpace(json)) return null; using var doc = JsonDocument.Parse(json); return doc.RootElement.Clone(); } catch (System.Text.Json.JsonException) { // Malformed/unexpected snapshot shape — degrade to no sub-type rather // than fail the whole approve/reject action over a display string. return null; } } // ── Auto-approve ───────────────────────────────────────────────────── public async Task ProcessAutoApprovalsAsync(LoginDTO systemLogin, CancellationToken cancellationToken) { // Cycle-level span — one per tenant per poll interval. using var cycleActivity = GB5ActivitySources.Background.StartActivity( "workflow.auto_approve.cycle", ActivityKind.Internal); cycleActivity?.SetTag("gb5.workflow.is_auto_approve", true); cycleActivity?.SetTag("gb5.tenant.id", systemLogin.ClientId); cycleActivity?.SetTag("gb5.tenant.db", systemLogin.DatabaseName); IReadOnlyList overdueTasks; try { overdueTasks = await _WorkFlowDAL.GetOverdueAutoApproveTasks(systemLogin); } catch (Exception ex) { cycleActivity?.AddException(ex); cycleActivity?.SetStatus(ActivityStatusCode.Error, ex.Message); _logger.LogError(ex, "WorkflowAutoApprove: failed to query overdue tasks. Tenant={TenantId} DB={DatabaseName}", systemLogin.ClientId, systemLogin.DatabaseName); return; } cycleActivity?.SetTag("gb5.workflow.auto_approve.task_count", overdueTasks.Count); if (overdueTasks.Count == 0) { cycleActivity?.SetStatus(ActivityStatusCode.Ok); return; } _logger.LogInformation( "WorkflowAutoApprove: {Count} overdue task(s) found. Tenant={TenantId} DB={DatabaseName}", overdueTasks.Count, systemLogin.ClientId, systemLogin.DatabaseName); int approved = 0, skipped = 0, failed = 0; foreach (var task in overdueTasks) { if (cancellationToken.IsCancellationRequested) break; var taskLogin = new LoginDTO { DatabaseName = systemLogin.DatabaseName, ConnectionDatabaseName = systemLogin.ConnectionDatabaseName, DatabaseType = systemLogin.DatabaseType, ClientId = task.TenantId, WorkOUId = task.OuId, UserId = -1, UserName = "AutoApproveService" }; var dto = new WorkFlowActionDTO { Items = new List { new WorkFlowActionItemDTO { TaskId = task.WorkflowTaskId, Action = (int)WorkflowAction.Approve, Comment = $"Auto-approved: SLA of {task.SlaHours} min elapsed." } } }; try { await WorkFlowActions(dto, taskLogin); approved++; _logger.LogInformation( "WorkflowAutoApprove: TaskId={TaskId} approved. " + "DueOn={DueOn} SlaMin={SlaMin} Tenant={TenantId} OU={OuId}", task.WorkflowTaskId, task.DueOn, task.SlaHours, task.TenantId, task.OuId); } catch (WorkflowEngineException wex) { // Task was manually actioned between the query and now — safe to skip. skipped++; _logger.LogWarning( "WorkflowAutoApprove: TaskId={TaskId} skipped — {Message}. Tenant={TenantId}", task.WorkflowTaskId, wex.Message, task.TenantId); } catch (Exception ex) { failed++; _logger.LogError(ex, "WorkflowAutoApprove: TaskId={TaskId} failed. " + "Tenant={TenantId} OU={OuId} DueOn={DueOn}", task.WorkflowTaskId, task.TenantId, task.OuId, task.DueOn); } } cycleActivity?.SetTag("gb5.workflow.auto_approve.approved", approved); cycleActivity?.SetTag("gb5.workflow.auto_approve.skipped", skipped); cycleActivity?.SetTag("gb5.workflow.auto_approve.failed", failed); cycleActivity?.SetStatus(failed > 0 ? ActivityStatusCode.Error : ActivityStatusCode.Ok); _logger.LogInformation( "WorkflowAutoApprove: cycle complete. " + "Approved={Approved} Skipped={Skipped} Failed={Failed} Tenant={TenantId}", approved, skipped, failed, systemLogin.ClientId); } // ── Approval lists ──────────────────────────────────────────────────── public async Task> WorkFlowApprovalList(int WorkflowType, int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { var list = await _WorkFlowDAL.WorkflowApprovalList(WorkflowType, FirstNumber, MaxResult, CriteriaDTO, LoginDTO) .ConfigureAwait(false); foreach (var item in list) { item.ParticularsTemplate = item.ParticularsTemplate?.Trim(); item.Particulars = ParticularsFormatter.Apply(item.ParticularsTemplate, item.DataJson, LoginDTO.DateFormat); } return list; } // ── History and status ──────────────────────────────────────────────── public async Task> WorkFlowHistory(int WorkflowType, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { var list = await _WorkFlowDAL.WorkflowHistory(WorkflowType, CriteriaDTO, LoginDTO); foreach (var item in list) { item.ParticularsTemplate = item.ParticularsTemplate?.Trim(); item.Particulars = ParticularsFormatter.Apply(item.ParticularsTemplate, item.DataJson, LoginDTO.DateFormat); } return list; } catch (Exception) { throw; } } public async Task> WorkFlowStatus(CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _WorkFlowDAL.WorkflowStatus(CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task> CurrentApprovalStatus( int entityId, int objectId, LoginDTO loginDTO) { try { return await _WorkFlowDAL.CurrentApprovalStatus(entityId, objectId, loginDTO); } catch (Exception) { throw; } } // ── Monitor dashboard ───────────────────────────────────────────────── public async Task GetWorkflowMonitorSummaryAsync( int entityId, DateTime? dateFrom, DateTime? dateTo, int page, int pageSize, LoginDTO loginDTO) { try { var summary = await _WorkFlowDAL.GetWorkflowMonitor( entityId, dateFrom, dateTo, page, pageSize, loginDTO); return JsonConvert.SerializeObject(summary); } catch (Exception ex) { _logger.LogError(ex, "GetWorkflowMonitorSummaryAsync failed for EntityId {EntityId}", entityId); throw; } } // ── Workflow CRUD ───────────────────────────────────────────────────── public async Task GetWorkflow(int WorkflowId, LoginDTO LoginDTO) { try { return await _WorkFlowDAL.GetWorkflow(WorkflowId, LoginDTO); } catch (Exception) { throw; } } public async Task SaveWorkflow(WorkflowDTO workflowDTO, LoginDTO loginDTO) { if (workflowDTO == null) throw new ArgumentNullException(nameof(workflowDTO)); workflowDTO.WorkflowModifiedById = loginDTO.UserId; workflowDTO.WorkflowModifiedOn = DateTime.UtcNow; bool isNew = workflowDTO.WorkflowId == 0; if (isNew) { workflowDTO.WorkflowCreatedById = loginDTO.UserId; workflowDTO.WorkflowCreatedOn = DateTime.UtcNow; int newId = await _WorkFlowDAL.SaveWorkflow(workflowDTO, loginDTO); return $"{SuccessResponse.SaveSuccessMessage} {newId}"; } else { await _WorkFlowDAL.UpdateWorkflow(workflowDTO, loginDTO); return $"{SuccessResponse.UpdateSuccessMessage} {workflowDTO.WorkflowId}"; } } public async Task DeleteWorkflow(int WorkflowId, LoginDTO LoginDTO) { try { var detailsResult = await _WorkFlowDAL.DeleteWorkflowDetail(WorkflowId, LoginDTO); if (detailsResult <= 0) return $"{ErrorResponse.DeleteNotFoundMessage}"; var parentResult = await _WorkFlowDAL.DeleteWorkflow(WorkflowId, LoginDTO); if (parentResult <= 0) return $"{ErrorResponse.DeleteNotFoundMessage}"; return $"{SuccessResponse.DeleteSuccessMessage}"; } catch (Exception ex) { throw new Exception($"Error Deleting Workflow ID {WorkflowId}", ex); } } public async Task GetSelectListWorkflow(CriteriaDTO criteriaDTO, LoginDTO LoginDTO) { try { var data = await _WorkFlowDAL.GetSelectListWorkflow(criteriaDTO, LoginDTO); return JsonConvert.SerializeObject(data); } catch (Exception) { throw; } } // ── Publish helpers — match EventHandler.cs BuildLoginJson / BuildContextJson ─ /// /// Serialises the full for inclusion in the published /// field. /// Falls back to a minimal identity JSON when serialisation fails. /// private static string BuildWorkflowLoginJson(LoginDTO login) { try { return JsonSerializer.Serialize(login, _publishSerialiserOptions); } catch (Exception ex) { return JsonSerializer.Serialize(new { ClientId = login.ClientId, UserId = login.UserId, WorkOUId = login.WorkOUId, ConnectionDatabaseName = login.ConnectionDatabaseName, SerializationError = ex.Message }); } } /// /// Builds the payload for a workflow /// outcome event, matching the shape produced by BaseEntityAppService.BuildContextJson. /// /// Bag is populated from TWORKFLOWINSTANCE.FACTSJSON — the DB_ENRICH scalar /// facts captured at workflow start (e.g. ReportingToEmployeeId, DepartmentId). /// EmailActionHandler reads Bag for EnrichQualifier (DeliveryType=12) recipient /// resolution and for ##FieldName## template token substitution via ContextBag cascade. /// /// Falls back to a minimal object when serialisation fails. /// private static string BuildWorkflowContextJson(WorkflowOutcomeInfo outcome, int eventTypeId, LoginDTO loginDTO) { try { // Parse FACTSJSON into a flat string→object? dictionary so EmailActionHandler // can read individual fields from ContextBag exactly as it does for entity saves. var bag = new Dictionary(StringComparer.OrdinalIgnoreCase); if (!string.IsNullOrWhiteSpace(outcome.FactsJson)) { try { using var doc = System.Text.Json.JsonDocument.Parse(outcome.FactsJson); foreach (var prop in doc.RootElement.EnumerateObject()) { bag[prop.Name] = prop.Value.ValueKind switch { JsonValueKind.String => (object?)prop.Value.GetString(), JsonValueKind.Number => prop.Value.TryGetInt64(out var l) ? (object?)l : prop.Value.GetDouble(), JsonValueKind.True => (object?)true, JsonValueKind.False => (object?)false, JsonValueKind.Null => null, _ => null // skip objects/arrays }; } } catch { /* malformed FactsJson — bag stays empty */ } } // Inject approver identity so ##UserName## / ##ApproverName## template tokens resolve. // UserName = system login name (e.g. "TRANSTEST") // ApproverName = DB_ENRICH display name for the approver (e.g. "Finana Fathima") // — EmployeeName without DTO. prefix is the reporting-to (approver) from FactsJson enrichment. if (!string.IsNullOrWhiteSpace(loginDTO.UserName)) bag["UserName"] = loginDTO.UserName; if (bag.TryGetValue("EmployeeName", out var enrichedApprover) && enrichedApprover is string approverDisplayName && !string.IsNullOrWhiteSpace(approverDisplayName)) bag["ApproverName"] = approverDisplayName; return JsonSerializer.Serialize(new { EntityId = outcome.EntityId, ObjectId = outcome.ObjectId, EventTypeId = eventTypeId, WorkflowInstanceId = outcome.WorkflowInstanceId, WorkflowId = outcome.WorkflowId, Action = outcome.Action, ActionByUserId = outcome.ActionByUserId, Comment = outcome.Comment, Facts = (object?)null, // no caller Facts on outcome path Bag = bag }, _publishSerialiserOptions); } catch (Exception ex) { return JsonSerializer.Serialize(new { EntityId = outcome.EntityId, ObjectId = outcome.ObjectId, EventTypeId = eventTypeId, SerializationError = ex.Message, Bag = new { } // empty Bag so ParseContextBag never silently returns empty }); } } } }