using System; using System.Collections.Generic; using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FrameworkBLL.WorkFlow; using FrameworkDAL.CustomCode.Entity; using FrameworkDAL.CustomCode.EntityViewer.DTOs; using FrameworkDAL.CustomCode.WebService; using FrameworkDAL.DTO.Workflow; using GB5Shared.ActionProcessor; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Qualifier; using GB5Shared.GOP.Qualifier; using Microsoft.Extensions.Logging; namespace FrameworkBLL.EntityViewer { // ============================================================ // EntityViewerBLL — given (ObjectTypeId = MENTITY.ENTITYID, ObjectId = the // record's PK), assembles the Entity Viewer's read-only detail payload from // three independent sources. Each source is best-effort: a failure in one // (e.g. no workflow instance, no MWEBSERVICE binding) never blocks the // others — GetEntityViewer always returns whatever it could resolve. // // 1. EntityData — MWEBSERVICE row for (ObjectTypeId, METHODTYPE=0/GET), // resolved to a real URL via the same IWebhookEndpointResolver // SysJob/DirectAction already use for MWEBSERVICE.SECONDURITEMPLATE, // then called over the "gb5-internal" HttpClient with the standard // GB5 Login-header auth convention. // 2. Workflow — IWorkFlowBLL.CurrentApprovalStatus(ObjectTypeId, // ObjectId, ...): current step, who has the task, full step history. // 3. Enrichment — IQualifierFacadeImpl OnDemand (Stage=9) qualifiers // bound to the entity's ENTITYCODE, same Stage/Scope convention // GetEnrichedDetail.cs already established for view-time enrichment. // Empty if no qualifier is configured for this entity — by design. // ============================================================ public class EntityViewerBLL : IEntityViewerBLL { private const byte MethodTypeGet = 0; private const byte StageOnDemand = 9; private const byte ScopeUi = 6; private const byte ScopeWorkflow = 1; private readonly IEntityDAL _EntityDAL; private readonly IWebServiceDAL _WebServiceDAL; private readonly IWebhookEndpointResolver _WebhookResolver; private readonly IHttpClientFactory _HttpClientFactory; private readonly IWorkFlowBLL _WorkFlowBLL; private readonly IQualifierFacadeImpl _QualifierFacade; private readonly ILogger _Logger; public EntityViewerBLL( IEntityDAL entityDAL, IWebServiceDAL webServiceDAL, IWebhookEndpointResolver webhookResolver, IHttpClientFactory httpClientFactory, IWorkFlowBLL workFlowBLL, IQualifierFacadeImpl qualifierFacade, ILogger logger) { _EntityDAL = entityDAL; _WebServiceDAL = webServiceDAL; _WebhookResolver = webhookResolver; _HttpClientFactory = httpClientFactory; _WorkFlowBLL = workFlowBLL; _QualifierFacade = qualifierFacade; _Logger = logger; } public async Task GetEntityViewer( int objectTypeId, int objectId, LoginDTO loginDTO, string? requestHost = null, CancellationToken ct = default) { var result = new EntityViewerResultDTO { ObjectTypeId = objectTypeId, ObjectId = objectId }; var entity = await _EntityDAL.GetEntityById(objectTypeId, loginDTO); result.EntityCode = string.IsNullOrEmpty(entity.Code) ? null : entity.Code; var (entityData, diagnostics) = await ResolveEntityDataAsync(objectTypeId, objectId, loginDTO, requestHost, ct); result.EntityData = entityData; result.EntityDataDiagnostics = diagnostics; result.Workflow = await ResolveWorkflowAsync(objectTypeId, objectId, loginDTO); if (result.EntityCode != null) result.Enrichment = await ResolveEnrichmentAsync(result.EntityCode, objectId, loginDTO, ct); return result; } // ── EntityData: MWEBSERVICE GET call ──────────────────────────────────── // Diagnostics is filled in at every exit point — success or failure — so // the caller never has to guess why EntityData is null. private const int MaxDiagnosticBodyChars = 2000; private async Task<(object? Data, EntityDataDiagnosticsDTO? Diagnostics)> ResolveEntityDataAsync( int objectTypeId, int objectId, LoginDTO loginDTO, string? requestHost, CancellationToken ct) { var diag = new EntityDataDiagnosticsDTO { RequestHost = requestHost }; try { var webServiceId = await _WebServiceDAL.GetWebServiceIdByEntityAndMethod( objectTypeId, MethodTypeGet, loginDTO); if (webServiceId == 0) { diag.Error = $"No GET (METHODTYPE=0) MWEBSERVICE binding found for ObjectTypeId={objectTypeId} " + "— check MWEBSERVICE.ENTITYID/METHODTYPE/STATUS."; _Logger.LogInformation("GetEntityViewer: {Error}", diag.Error); return (null, diag); } diag.WebServiceId = webServiceId; var resolvedUrl = await _WebhookResolver.ResolveAsync( webServiceId, objectId.ToString(), loginDTO.ClientId, loginDTO.DatabaseName, ct, baseUriOverride: requestHost); if (string.IsNullOrWhiteSpace(resolvedUrl)) { diag.Error = $"WebServiceId={webServiceId} resolved to no URL — its URITEMPLATE/" + "SECONDURITEMPLATE contains {BaseURI} but neither requestHost " + $"('{requestHost}') nor SysJobSettings:Gb4ServiceBaseUrl config supplied a value."; _Logger.LogWarning("GetEntityViewer: {Error}", diag.Error); return (null, diag); } diag.ResolvedUrl = resolvedUrl; using var client = _HttpClientFactory.CreateClient("gb5-internal"); client.DefaultRequestHeaders.Remove("Login"); client.DefaultRequestHeaders.Add("Login", JsonSerializer.Serialize(loginDTO)); using var response = await client.GetAsync(resolvedUrl, ct); var body = await response.Content.ReadAsStringAsync(ct); diag.StatusCode = (int)response.StatusCode; diag.RawResponseBody = Truncate(body); if (!response.IsSuccessStatusCode) { diag.Error = $"HTTP {(int)response.StatusCode} ({response.ReasonPhrase}) from {resolvedUrl} " + "— see RawResponseBody for what the target actually returned."; _Logger.LogWarning("GetEntityViewer: {Error}", diag.Error); return (null, diag); } if (string.IsNullOrWhiteSpace(body)) { diag.Success = true; diag.Error = $"HTTP {(int)response.StatusCode} from {resolvedUrl} but the response body was empty."; return (null, diag); } // The resolved URL is itself a GB5-native endpoint, so its response is the // standard ResponseStandardDTO envelope (Id/To/Status/Body/CacheKey/...) — unwrap // to Body, the actual payload (e.g. the TimeSlip record), same as any other GB5 // consumer would. Fall back to the raw parsed JSON if there's no "Body" property, // so a non-GB5 target (a genuinely external webhook) still returns something. var parsed = JsonSerializer.Deserialize(body); var data = parsed.ValueKind == JsonValueKind.Object && parsed.TryGetProperty("Body", out var bodyEl) ? bodyEl : parsed; // Success — no diagnostics block; the caller doesn't need it when EntityData is populated. return (data, null); } catch (OperationCanceledException) { throw; } catch (Exception ex) { diag.Error = $"{ex.GetType().Name}: {ex.Message}"; _Logger.LogError(ex, "GetEntityViewer: EntityData resolution failed for ObjectTypeId={ObjectTypeId} ObjectId={ObjectId}", objectTypeId, objectId); return (null, diag); } } private static string? Truncate(string? s) => string.IsNullOrEmpty(s) || s.Length <= MaxDiagnosticBodyChars ? s : s.Substring(0, MaxDiagnosticBodyChars) + "... (truncated)"; // ── Workflow: current approval status ─────────────────────────────────── private async Task> ResolveWorkflowAsync( int objectTypeId, int objectId, LoginDTO loginDTO) { try { var list = await _WorkFlowBLL.CurrentApprovalStatus(objectTypeId, objectId, loginDTO); foreach (var status in list) Humanize(status); return list; } catch (Exception ex) { _Logger.LogWarning(ex, "GetEntityViewer: workflow status lookup failed for ObjectTypeId={ObjectTypeId} ObjectId={ObjectId}", objectTypeId, objectId); return Array.Empty(); } } // Fills in reader-facing text for the descriptive string fields that are legitimately // null when there's nothing to report (no prior step, no next step yet, no assignee) — // scoped to GetEntityViewer's own output only; WorkFlowBLL/WorkFlowQB's underlying data // (and every other caller of CurrentApprovalStatus) keeps its real nulls untouched. // Deliberately NOT applied to DateTime? fields (LastCompletedOn/CurrentAssignedOn/ // CurrentCompletedOn) — a fabricated timestamp string would be misleading; a missing // date is normal, unambiguous API shape and stays null. private static void Humanize(WorkflowStatusDTO status) { status.CurrentStepKey ??= status.CurrentStepId?.ToString(); status.DisplayName ??= "—"; status.CurrentStep ??= status.DisplayName; status.CurrentAssignedUserName ??= "Unassigned"; status.AssignedTo ??= status.CurrentAssignedUserName; status.SubmittedBy ??= "—"; status.CurrentAction ??= "No action taken yet"; status.CurrentComment ??= "No remarks"; status.LastCompletedStep ??= "None — no prior step completed yet"; status.LastCompletedBy ??= "—"; // Null here means either "this is the last step in the definition" or "the next // step hasn't been created yet" — the query can't tell those apart (see comment on // WorkFlowQB.GetCurrentApprovalStatus's NEXTPENDING), so the text stays neutral. status.NextPendingStep ??= "No further step pending yet"; status.NextPendingWith ??= "—"; status.StepHistory ??= "No history yet"; } // ── Enrichment: OnDemand qualifiers (Scope=UI, Scope=Workflow) ───────── private async Task> ResolveEnrichmentAsync( string entityCode, int objectId, LoginDTO loginDTO, CancellationToken ct) { var merged = new Dictionary(); foreach (var scope in new[] { ScopeUi, ScopeWorkflow }) { try { var ctx = new QualifierExecutionContext { ClientId = loginDTO.ClientId, EntityId = objectId, EntityCode = entityCode, Stage = StageOnDemand, Scope = scope, Document = JsonDocument.Parse("{}"), Facts = new QualifierFactBag(), CorrelationId = objectId.ToString(), CancellationToken = ct }; var qualifierResult = await _QualifierFacade.ExecuteStageAsync(ctx, loginDTO, ct); if (qualifierResult.Facts.TryGetValue("Document", out var doc) && doc is IReadOnlyDictionary docFacts) { foreach (var kv in docFacts) merged[kv.Key] = kv.Value; } } catch (Exception ex) { _Logger.LogWarning(ex, "GetEntityViewer: OnDemand qualifier enrichment failed | EntityCode={EntityCode} Scope={Scope}", entityCode, scope); } } return merged; } } }