// ╔══════════════════════════════════════════════════════════════════╗ // ║ GB5 Framework — Program.cs ║ // ║ .NET 9 | Minimal API | FastEndpoints | Dapr ║ // ╚══════════════════════════════════════════════════════════════════╝ #region ── Using Directives ──────────────────────────────────────────── using Dapr.Client; using FrameworkSL.ReportOrchestration; using FastEndpoints; using FastEndpoints.Swagger; using FluentValidation.Validators; using FrameworkBLL.ActionProcessor.Handlers; using FrameworkBLL.AutoScheduler; using FrameworkBLL.EIPConcept; using FrameworkBLL.EIPConversation; using FrameworkBLL.EIPConversation.EIPHandlers.EIPActionExecutor; using FrameworkBLL.EIPConversation.EIPHandlers.EIPConversationBLL; using FrameworkBLL.EIPConversation.EIPHandlers.EIPConversationEngine.EIPRoutingRepository; using FrameworkBLL.EIPConversation.EIPHandlers.EIPEngine.CapabilityEngine; using FrameworkBLL.EIPConversation.EIPHandlers.EIPEngine.FlowEngine; using FrameworkBLL.EIPConversation.EIPHandlers.EIPEngine.RoutingEngine; using FrameworkBLL.EIPConversation.EIPHandlers.EIPEngine.TenantContext; using FrameworkBLL.EIPConversation.EIPHandlers.EIPFlowRepository; using FrameworkBLL.Encrypt; using FrameworkBLL.MessageHub; using FrameworkBLL.MessageHub.MessageHubPlatforms; using FrameworkBLL.Role; using FrameworkBLL.SchedulerTaskGenerator; using FrameworkBLL.GOP; using FrameworkBLL.GOP.Transform; using FrameworkBLL.GOP.Worker; using FrameworkBLL.GOP.Worker.NodeExecutors; using GB5Shared.Auth.Jwt; using GB5Shared.GOP.Qualifier; using GB5Shared.GOP.Qualifier.Executors; using GB5Shared.GOP.GBQueryExecutor; using GB5Shared.GOP.GBQueryExecutor.Models; using GB5Shared.GOP.GBQueryExecutor.DTOs; using FrameworkBLL.SchedulerTaskGeneratorPublisher; using FrameworkBLL.WorkFlow; using FrameworkBLL.SystemJob; using FrameworkSL.Hubs.GOP; using FrameworkSL.Hubs.EIPChat; using FrameworkSL.Hubs.SysJob; using GB5Shared.WorkFlow.Wip; using GB5Shared.ActionProcessor; using FrameworkDAL.CustomCode.ActionProcessor; using FrameworkDAL.CustomCode.AutoScheduler; using FrameworkDAL.CustomCode.EIPConversation.EIPCapability; using FrameworkDAL.CustomCode.EIPConversation.EIPCapabilityActionMap; using FrameworkDAL.CustomCode.EIPConversation.EIPFlow; using FrameworkDAL.CustomCode.EIPConversation.EIPRouting; using FrameworkDAL.CustomCode.HybridCache; using FrameworkDAL.CustomCode.MessageHub; using FrameworkDAL.CustomCode.SchedulerTaskGenerator; using FrameworkDAL.CustomCode.WorkFlow; using FrameworkDAL.DTO.EIPConversation; using FrameworkDAL.DTO.KeyCloak; using FrameworkDAL.DTO.MessageHub; using FrameworkDAL.DTO.MessageHub.MessageHubGenerator; using FrameworkDAL.DTO.Scheduler; using FrameworkDAL.DTO.SchedulerTaskGenerator; using FrameworkSL.Controllers.ActionProcessor; using FrameworkSL.Controllers.Events; using FrameworkSL.Controllers.PubSub; using FrameworkSL.Controllers.SysJob; using GB5Shared.Connection; using GB5Shared.DateConverter; using static GB5Shared.DateConverter.GB5JsonOptions; using GB5Shared.DTO.Framework.CommonConfig; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.SSO; using GB5Shared.EntityHandler; using GB5Shared.EventLogPublish; using GB5Shared.ExcelExport; using GB5Shared.Export.CSVExport; using GB5Shared.Export.Pivot; using GB5Shared.GB5CommonFunction; using GB5Shared.GB5Library.Qualifier; using GB5Shared.GenerateAutoNumber; using GB5Shared.Middleware; using GB5Shared.GOP.ExecuteAsync; using GB5Shared.OTP; using GB5Shared.PubSub.OutBox; using GB5Shared.QueryExecutor; using GB5Shared.SSO; using GB5Shared.Storage; using GB5Shared.Vault; using GB5Shared.Deployment; using GB5Shared.Validation; using GB5Shared.WorkFlow.WorkFlowEngine; using GB5Shared.WorkFlow.WorkFlowRunTime; using MassTransit; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.OpenApi; using GB5Shared.Telemetry; using Quartz; using Serilog; using Serilog.Formatting.Json; using StackExchange.Redis; using System.Diagnostics; using System.Reflection; using System.Text; using System.Text.Json.Serialization; using GB5Shared.DaprCache; #endregion // ═══════════════════════════════════════════════════════════════════ // Bootstrap // ═══════════════════════════════════════════════════════════════════ var builder = WebApplication.CreateBuilder(args); builder.WebHost.ConfigureKestrel(options => { options.Limits.MaxRequestBodySize = 100 * 1024 * 1024; // 100 MB }); // ── Port from config — change "AppPort" in appsettings.json to use any port ── var appPort = builder.Configuration.GetValue("AppPort"); builder.WebHost.UseUrls($"http://0.0.0.0:{appPort}"); #region ── Console Encoding ──────────────────────────────────────────── // UTF-8 enables structured characters on all OS (Windows, Linux, macOS) Console.OutputEncoding = Encoding.UTF8; #endregion // ── Startup self-diagnosis ────────────────────────────────────────── // Every startup-phase marker below is timestamped with elapsed time since process // launch, so a slow boot (blocking IHostedService, unreachable dependency, etc.) // shows up directly in the console/Serilog output instead of requiring a profiler — // find the biggest gap between two consecutive lines to find the culprit. var startupSw = Stopwatch.StartNew(); void StartupLog(string message) => Console.WriteLine($"[startup +{startupSw.Elapsed:mm\\:ss\\.fff}] {message}"); // ═══════════════════════════════════════════════════════════════════ // Configuration Sources // ▸ appsettings.json → core application config (required) // ▸ SchedulerRun.json → scheduler toggle + connection (optional) // If SchedulerRun.json is absent the app starts normally // with the scheduler disabled — no crash, no exception. // ═══════════════════════════════════════════════════════════════════ builder.Configuration .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .AddJsonFile("SchedulerRun.json", optional: true, reloadOnChange: true); // SMS / Twilio settings builder.Services.Configure( builder.Configuration.GetSection("Twilio")); // ── Template-based Attachment Path Resolution ───────────────────────── // IAttachmentUploadService, ITemplateResolutionEngine, IAttachmentPathResolutionService now // live in GB5Shared.Attachment (moved out of FrameworkBLL) — GB5Shared isn't in the // FrameworkBLL/FrameworkDAL scan's FromAssemblies list, so these need explicit registration. builder.Services.Configure( builder.Configuration.GetSection(GB5Shared.Attachment.AttachmentPathSettings.Section)); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddGB5Storage(builder.Configuration.GetSection("StorageConfiguration")); // IFileUploadBLL/IAttachmentDAL now live in GB5Shared.FileUpload (moved out of FrameworkBLL/ // FrameworkDAL) — same reason as above, GB5Shared isn't in the scan's FromAssemblies list. builder.Services.AddScoped(); builder.Services.AddScoped(); // ── Orphan Attachment Cleanup ───────────────────────────────────────── // Config binding: appsettings.json "OrphanCleanup" section // IAttachmentResolutionService is auto-registered by the assembly scan below. // OrphanAttachmentCleanupJob must be explicit — IHostedService is excluded from the scan. builder.Services.Configure( builder.Configuration.GetSection("OrphanCleanup")); builder.Services.AddHostedService(); // Workflow SLA auto-approve background service if (builder.Configuration.GetValue("WorkflowAutoApprove:Enabled")) builder.Services.AddHostedService(); // Draft row extension — config + orphan cleanup job builder.Services.Configure( builder.Configuration.GetSection("DraftSettings")); if (builder.Configuration.GetValue("DraftSettings:OrphanIntervalMinutes") > 0) builder.Services.AddHostedService(); // ═══════════════════════════════════════════════════════════════════ // Feature Flags // Read BEFORE any service registration so all guards are consistent. // // schedulerEnabled Y = Quartz + RabbitMQ + SchedulerTaskManager ON // N = all scheduler services skipped // celitixEnabled Y = Celitix messaging platform ON // eipEnabled Y = EIP Conversation Engine ON (standalone, no scheduler required) // ═══════════════════════════════════════════════════════════════════ bool schedulerEnabled = builder.Configuration["SchedulerRun:Enable"]?.Trim().ToUpper() == "Y"; bool celitixEnabled = builder.Configuration["MessageHubSettings:Celitix:Enable"]?.Trim().ToUpper() == "Y"; bool eipEnabled = builder.Configuration["EIP:Enable"]?.Trim().ToUpper() == "Y"; // Startup status — visible in console before Serilog initialises StartupLog(schedulerEnabled ? "Scheduler Mode : ENABLED — Quartz + RabbitMQ will start" : "Scheduler Mode : DISABLED — Quartz + RabbitMQ will NOT start"); StartupLog(celitixEnabled ? "Celitix Mode : ENABLED" : "Celitix Mode : DISABLED"); StartupLog(eipEnabled ? "EIP Mode : ENABLED — Conversation Engine will start" : "EIP Mode : DISABLED — set EIP:Enable=Y to activate standalone"); // ═══════════════════════════════════════════════════════════════════ // Main Service Registration Block // All framework, EIP, MessageHub, and workflow services live here. // Guarded by (schedulerEnabled || celitixEnabled || eipEnabled) — // if all three are disabled, the app starts as a lightweight shell. // ═══════════════════════════════════════════════════════════════════ if (schedulerEnabled || celitixEnabled || eipEnabled) { #region ── Logging — Serilog ─────────────────────────────────────── // Console → structured template output // File → rolling daily JSON log (Logs/log-YYYYMMDD.json) builder.Logging.ClearProviders(); builder.Logging.AddConsole(); builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Information); Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Information) .MinimumLevel.Override("Microsoft.Hosting.Lifetime", Serilog.Events.LogEventLevel.Information) .MinimumLevel.Override("System", Serilog.Events.LogEventLevel.Warning) .MinimumLevel.Override("FrameworkDAL", Serilog.Events.LogEventLevel.Error) .MinimumLevel.Override("GB5Shared", Serilog.Events.LogEventLevel.Error) .Enrich.FromLogContext() .WriteTo.Console( outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .WriteTo.File( new JsonFormatter(), "Logs/log-.json", rollingInterval: RollingInterval.Day) .CreateLogger(); builder.Host.UseSerilog(); #endregion #region ── HttpContext + Session ─────────────────────────────────── builder.Services.AddHttpContextAccessor(); builder.Services.AddDistributedMemoryCache(); builder.Services.AddSession(options => { options.IdleTimeout = TimeSpan.FromHours(1); options.Cookie.HttpOnly = true; options.Cookie.IsEssential = true; options.Cookie.SameSite = SameSiteMode.None; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; }); #endregion #region ── Dapr Client ───────────────────────────────────────────── builder.Services.AddDaprClient(); #endregion #region ── Action Processor Handlers ─────────────────────────────── // SMTP outbound email — settings from appsettings.json "Smtp" section builder.Services.Configure( builder.Configuration.GetSection("Smtp")); // Webhook HTTP client (30s timeout) builder.Services.AddHttpClient("webhook", client => { client.Timeout = TimeSpan.FromSeconds(30); }); builder.Services.AddScoped(); // IActionHandler implementations (EmailActionHandler, SmsActionHandler, WebhookActionHandler, // CorrespondenceActionHandler) and IActionHandlerResolver are registered by the BLL assembly scan below. // EipNotificationActionHandler (ActionType=5) lives in FrameworkSL (needs IHubContext) — register explicitly. builder.Services.AddScoped(); // GB5Shared.ActionProcessor.IEventActionRunDAL/IActionOutboxDAL — needed unconditionally by // PlaygroundBLL, EventSubBLL, SchedulerActionGeneratorBLL, and NotificationActionHandler, not // just the Scheduler-Quartz-jobs block below. GB5Shared isn't in the FrameworkBLL/FrameworkDAL // scan's FromAssemblies list, so these need explicit registration regardless of Scheduler mode. builder.Services.AddScoped(); builder.Services.AddScoped(); // GB5Shared.DirectAction — needed by EmailActionHandler and DirectActionExecuteActionHandler // (Approve/Reject/Return email-link actions). Not covered by the FrameworkBLL/FrameworkDAL scan. builder.Services.AddScoped(); builder.Services.AddScoped(); // GB5Shared.Attachment.IAttachmentResolveDAL — needed by FrameworkBLL.Attachment.AttachmentResolveSubBLL. builder.Services.AddScoped(); // GB5Shared.IceMap — needed by FrameworkSL.Endpoints.IceMap.* endpoints (mapping metadata CRUD // stays framework-level). Not covered by the FrameworkBLL/FrameworkDAL scan. builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion #region ── Controllers + JSON Serialisation ──────────────────────── builder.Services.AddControllers() .AddDapr() .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = null; options.JsonSerializerOptions.DictionaryKeyPolicy = null; options.JsonSerializerOptions.AddGB5Converters(); }); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.PropertyNamingPolicy = null; options.SerializerOptions.DictionaryKeyPolicy = null; options.SerializerOptions.AddGB5Converters(); }); #endregion #region ── Options Binding ───────────────────────────────────────── builder.Services.Configure(builder.Configuration.GetSection("Oidc")); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("Gb5SystemDTO")); builder.Services.Configure( builder.Configuration.GetSection(FrameworkBLL.AuditQuery.ArchiveSettings.Section)); #endregion #region ── Redis + HybridCache ───────────────────────────────────── builder.Services.AddSingleton(sp => { var conn = builder.Configuration.GetValue("Redis:ConnectionString") ?? "localhost:6379"; return ConnectionMultiplexer.Connect($"{conn},abortConnect=false"); }); #pragma warning disable EXTEXP0018 builder.Services.AddHybridCache(); #pragma warning restore EXTEXP0018 builder.Services.AddScoped(); #endregion #region ── Core Infrastructure Services ──────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient("ReportClient", client => { client.Timeout = TimeSpan.FromMinutes(10); }); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddReportOrchestration(); // Report Orchestration Platform — FrameworkSL has no module endpoints of its own builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Rule engine condition evaluator (GB5Shared — not covered by FrameworkBLL assembly scan) builder.Services.AddScoped(); #endregion #region ── Login DTO (per-request from HTTP header) ──────────────── // ⚠ FOR HTTP ENDPOINTS ONLY. // Background services (Quartz, SchedulerBackgroundService) must NEVER // resolve LoginDTO from this registration — they use BuildLoginFromConfig() // directly from IConfiguration. This registration throws if Login header // is missing, which is expected for HTTP endpoints but fatal for bg services. builder.Services.AddScoped(sp => { var httpContext = sp.GetRequiredService().HttpContext; if (httpContext != null && httpContext.Request.Headers.TryGetValue("Login", out var loginHeader)) return Newtonsoft.Json.JsonConvert.DeserializeObject(loginHeader!)!; // ✅ Return empty DTO instead of throwing — prevents crashes in // background scopes that transitively touch this registration return new LoginDTO(); }); #endregion #region ── Database Contexts ─────────────────────────────────────── // CentralDbContext → GB5 system database (audit, central config) // Gb4DbContext → GB4 scheduler base database (read-only views) string gb5SystemConn = builder.Configuration["Gb5SystemDTO:Gb5System"] ?? throw new InvalidOperationException("Gb5SystemDTO:Gb5System is not configured in appsettings.json"); string? baseSchedulerConn = builder.Configuration["Gb5SystemDTO:SchedulerTemplate"]; builder.Services.AddDbContext(o => o.UseSqlServer(gb5SystemConn)); if (!string.IsNullOrWhiteSpace(baseSchedulerConn)) { builder.Services.AddDbContextFactory(o => o.UseSqlServer(baseSchedulerConn)); } else { // SchedulerTemplate not configured — register a no-op factory so DI resolution // does not crash on startup. The factory will throw at first USE if the // scheduler tries to open a Gb4DbContext without a connection string. builder.Services.AddDbContextFactory(o => o.UseSqlServer("Server=.;Database=placeholder;Trusted_Connection=True;")); } #endregion #region ── Workflow Engine ───────────────────────────────────────── builder.Services.AddScoped< GB5Shared.WorkFlow.WorkFlowEngine.IUserDelegationResolver, GB5Shared.WorkFlow.WorkFlowEngine.UserDelegationResolver>(); builder.Services.AddScoped< GB5Shared.WorkFlow.WorkFlowRunTime.IWorkFlowRunTime, GB5Shared.WorkFlow.WorkFlowRunTime.WorkFlowRunTime>(); builder.Services.AddScoped< GB5Shared.WorkFlow.WorkFlowEngine.IWorkFlowEngine, GB5Shared.WorkFlow.WorkFlowEngine.WorkFlowEngine>(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion // ── WIP Approval HTTP client (internal service-to-service replay) ── // CALLBACKENDPOINT in MWORKFLOWCONFIG must be the FULL URL of the target service endpoint. // Example: http://task-service:5000/Task/SaveTask builder.Services.AddHttpClient("wip-replay", client => { client.Timeout = TimeSpan.FromSeconds(30); }); #region ── GBQueryExecutor — Safe Configured-Query Service ───────────────────── // IGBQueryExecutor enforces 6 safety rules: SELECT-only, read-only connection, // TenantId auto-injection, parameterized binding, hard row cap, timeout ceiling. // Required by DataSourceExecutor inside QualifierFacadeImpl for DataSource-mode qualifiers. // Explicit (was auto-discovered via assembly scan before this subsystem moved into // GB5Shared for the qualifier-engine consolidation — GB5Shared isn't in the scan's // FromAssemblies list, so these now need explicit registration). builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion // IMemoryCache — used by QualifierFacadeImpl for L2 plan snapshot caching. builder.Services.AddMemoryCache(); #region ── GOP / Qualifier Engine ────────────────────────────────── builder.Services.AddScoped(typeof(BaseEntityAppService<>), typeof(BaseEntityAppService<>)); builder.Services.AddScoped(); builder.Services.AddGB5QualifierEngine(); builder.Services.AddScoped(); // used by GetResponseAPI — generic ad-hoc HTTP-call-with-retry endpoint, separate from the Flow pipeline #endregion #region ── GOP Qualifier Engine #1 (real, DAG-scheduled, admin-authored) — Explicit ───── // The versioned/DAG/rule-condition qualifier engine — QualifierNodeExecutor (GOP pipeline) // and EIPFlowEngine's QUALIFY step both call IQualifierFacadeImpl. Relocated into GB5Shared // as part of the qualifier-engine consolidation (single engine reachable from every // GB5Solution module, not just FrameworkBLL) — was previously auto-discovered via the // assembly scan below; GB5Shared isn't in that scan's FromAssemblies list, so these need // explicit registration now. builder.Services.AddScoped(); builder.Services.AddScoped(); // PushNotification cluster moved into GB5Shared (shared by ECPBLL + FrameworkSL) — same // reason as above, GB5Shared isn't in the scan's FromAssemblies list. builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Bulk Qualifier Run — "test this Qualifier against a batch of records" runner. Also // lives in GB5Shared (same consolidation reason as the rest of this region). builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion #region ── GOP Pipeline — Node Executors (keyed by NodeType) ──────────────── // GopExecutionPipeline resolves: sp.GetKeyedService(step.NodeType) // Each executor handles one NodeType value. Add new NodeTypes here without modifying // the pipeline class itself (open/closed principle). builder.Services.AddKeyedScoped("Qualifier"); builder.Services.AddKeyedScoped("Mapper"); builder.Services.AddKeyedScoped("ApiCall"); builder.Services.AddKeyedScoped("Approval"); // Metadata Promotion — see /Users/venkatv/.claude/plans/as-part-of-developement-indexed-stonebraker.md builder.Services.AddKeyedScoped("PromotionExport"); builder.Services.AddKeyedScoped("PromotionIngest"); builder.Services.AddKeyedScoped("PromotionImport"); // AIExtract — converts unstructured content to JSON via the external Enterprise AI engine // (AI-Enterprise-v1.0, separate repo, confirmed live at http://217.217.249.121:8006). builder.Services.AddKeyedScoped("AIExtract"); #endregion #region ── Metadata Promotion — Entity Adapters (keyed by EntityTypeCode) ─── // PromotionExport/Ingest/ImportNodeExecutor resolve: // sp.GetKeyedService(bundle.EntityTypeCode) — same open/closed // pattern as the NodeExecutor registrations above. Register each entity's adapter // here as it's wired — see the plan's Implementation order. builder.Services.AddKeyedScoped("REPORTVIEW"); builder.Services.AddKeyedScoped("DRILLDOWN"); builder.Services.AddKeyedScoped("REPORTHEADER"); builder.Services.AddKeyedScoped("METAREPORT"); builder.Services.AddKeyedScoped("PAGE"); builder.Services.AddKeyedScoped("DASHBOARD"); builder.Services.AddKeyedScoped("PORTLETTYPE"); builder.Services.AddKeyedScoped("PORTLET"); // Cross-host entities — TrainingProgramme (TMSBLL) lives in HRFinanceHost, a separate process // with no DI container overlap with this Framework host. RemoteHttpPromotionAdapter reaches // its LOCAL adapter (registered inside TMSModule.Register(), same keyed-DI pattern) over HTTP // via the "promotion-remote" named HttpClient below. See // GB5Shared/DTO/Promotion/PromotionBridgeDTOs.cs for the full reasoning. builder.Services.AddKeyedScoped("TRAININGPROGRAMME", (sp, _) => new FrameworkBLL.Promotion.Adapters.RemoteHttpPromotionAdapter( sp.GetRequiredService(), sp.GetRequiredService(), entityTypeCode: "TRAININGPROGRAMME", moduleConfigKey: "TMS")); // Work Instruction (WiBLL) lives in EngagementHost — same cross-host reasoning as TMS above. // WiSL's own PromotionBridge endpoints serve all three of these (and, once wired, CMS's own // entities too) since resolution is by EntityTypeCode, not tied to one specific module. foreach (var wiEntityCode in new[] { "WIPROCESS", "WISUBPROCESS", "WORKINSTRUCTION" }) { var code = wiEntityCode; // capture per-iteration value for the closure below builder.Services.AddKeyedScoped(code, (sp, _) => new FrameworkBLL.Promotion.Adapters.RemoteHttpPromotionAdapter( sp.GetRequiredService(), sp.GetRequiredService(), entityTypeCode: code, moduleConfigKey: "EngagementHost")); } #endregion #region ── Qualifier Engine — Method Executors (keyed by ExecutionMode) ───── // QualifierFacadeImpl resolves: sp.GetKeyedService(modeKey) // ("NCalc"/"DataSource"/"Service"). These registrations were previously missing // entirely -- every qualifier method silently no-op'd (GetKeyedService returned // null, logged and skipped, no exception) regardless of ExecutionMode. builder.Services.AddKeyedScoped("NCalc"); builder.Services.AddKeyedScoped("DataSource"); builder.Services.AddKeyedScoped("Service"); #endregion #region ── Mapping Engine — Transform Expressions ─────────────────────────── // Registers all 9 built-in ITransformExpression implementations as Singletons. // MappingEngineBLL receives IEnumerable via constructor injection. // To add a custom transform: builder.Services.AddTransformExpression(); builder.Services.AddMappingTransforms(); #endregion #region ── GOP SignalR Hub Notifier ───────────────────────────────────────── // GopHubNotifier wraps IHubContext. // Registered as Singleton (IHubContext itself is thread-safe Singleton). // GopExecutionPipeline and GopWorkerService inject IGopHubNotifier (BLL interface) // — never the ASP.NET IHubContext directly — preserving the BLL/SL separation. builder.Services.AddSingleton(); #endregion #region ── SysJob Hub Notifier ────────────────────────────────────────────── // SysJobHubNotifier wraps IHubContext. // SysJobShareBLL injects ISysJobHubNotifier (BLL interface) to push JobShared // notifications to the recipient user's SignalR group without referencing SL types. builder.Services.AddSingleton(); #endregion #region ── Dashboard Hub Notifier ─────────────────────────────────────────── // DashboardHubNotifier wraps IHubContext. Any FrameworkBLL // class in THIS process can inject IDashboardHubNotifier to push a "dataset changed" // notification; a BLL on a different host calls POST /Dashboard/NotifyDataChanged instead — // see IDashboardHubNotifier.cs for why. builder.Services.AddSingleton(); #endregion #region ── DataSync Hub Notifier ──────────────────────────────────────────── // DataSyncHubContext adapts IDataSyncHubContext (BLL) to IHubContext. // Scoped so it can safely resolve SignalR infrastructure per request/scope. builder.Services.AddScoped(); #endregion #region ── DataSync Scheduler Job ─────────────────────────────────────────── // IHostedService is excluded from the assembly scan — must be registered explicitly. // DataSyncSchedulerJob polls TDSYNCJOB every 60 s and triggers enabled jobs whose // cron expression has elapsed. Uses IServiceScopeFactory for Scoped dependency access. builder.Services.AddHostedService(); #endregion #region ── GOP Worker Service ─────────────────────────────────────────────── // IHostedService is excluded from the assembly scan — must be registered explicitly. // GopWorkerService polls TGopExecutionHeader every 5 s and dispatches executions // to GopExecutionPipeline. Uses IServiceScopeFactory for Scoped dependency access. // GopExecutionPipeline and GopExecutionLockService are concrete classes (not // behind an interface), so the assembly scan never picks them up — must also // be registered explicitly, or every poll cycle throws "No service for type // '...'" (confirmed live 2026-07-24: GopExecutionPipeline was the first one // found; fixing it immediately surfaced GopExecutionLockService as a second, // separate instance of the same bug). builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); #endregion #region ── OpenTelemetry (Tracing + Metrics) ──────────────────────── // Unified config-driven OTel setup. Exporter / sampler / environment are // controlled from appsettings.json "OpenTelemetry" section. // Defaults: Zipkin → http://localhost:9411, always-sample, metrics enabled. builder.Services.AddGB5Telemetry(builder.Configuration, "GB5-FRAMEWORK"); #endregion #region ── Vault (Singleton — IVaultClient + resilience pipeline + IVaultService) ── builder.Services.AddGB5Vault(builder.Configuration); #endregion #region ── Deployment tier (Singleton — GB5:Environment=Dev|QC|Live) ── builder.Services.AddGB5Environment(builder.Configuration); #endregion #region ── JWT issuance (shared IJwtAccessTokenIssuer/IJwtSigningKeyResolver) ── // Requires AddGB5Vault above. Used today by AIExtractNodeExecutor to mint tokens for the // external Enterprise AI engine; the same shared mechanics DXP/Partner/Entitlement use for // their own M2M tokens. builder.Services.AddGB5JwtIssuer(); builder.Services.Configure( builder.Configuration.GetSection(FrameworkBLL.GOP.Worker.AI.AIExtractOptions.SectionName)); #endregion #region ── Multi-tenant Keycloak JWKS cache ── // Requires AddGB5Vault above. Used by Endpoints/KeyCloak/AuthenticateUserToken.cs (the live // /Authorize/* login path, migrated off the old MVC KeyCloakService controller per H-17) to // verify a Keycloak access token's signature before trusting it, and later by the // multi-tenant AddJwtBearer scheme once that's registered here (GB5 Repo-Wide Authentication // Hardening plan, Phase 3/5). builder.Services.AddGB5MultiTenantJwtBearerSupport(); // Explicit (not assembly-scan) registration — this is the security-critical bridge // BaseEndPoint.GetLoginDTOFromRequestAsync calls when a Keycloak scheme has already validated // a request's token. Deliberately spelled out here rather than left to scan convention. Lives // in GB5Shared (not FrameworkBLL) specifically so the same implementation registers // identically across all five hosts — see GB5Shared/Auth/Jwt/KeycloakLoginDTOResolver.cs's // class-level comment. builder.Services.AddScoped(); #endregion #region ── Multi-tenant Keycloak JWT Bearer scheme (GB5 Repo-Wide Authentication Hardening, Phase 5 pilot) ── // Additive only — this host had NO AddAuthentication()/UseAuthentication() at all before this. // Zero behavior change for every existing caller: AllowAnonymous() endpoints (still the // default everywhere) ignore auth schemes entirely regardless of what's registered here, and // BaseEndPoint.GetLoginDTOFromRequestAsync's dual-mode bridge falls straight through to // today's trusted-header path whenever no valid Keycloak token is presented — which is every // request until a caller actually sends `Authorization: Bearer ` from the // now-fixed Endpoints/KeyCloak/ login flow. TokenValidationParameters here is a placeholder — // MultiTenantJwtBearerEvents.Build()'s OnMessageReceived replaces it per-request (or calls // NoResult()/Fail()) before any validation actually runs; ValidateIssuer=false here only // matters for the (never-reached-in-practice) case where OnMessageReceived's own replacement // somehow didn't run. builder.Services.AddAuthentication(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, }; options.Events = GB5Shared.Auth.Jwt.MultiTenantJwtBearerEvents.Build(); }); builder.Services.AddAuthorization(); #endregion #region ── SignalR ───────────────────────────────────────────────── builder.Services.AddSignalR(options => { options.EnableDetailedErrors = builder.Environment.IsDevelopment(); options.MaximumReceiveMessageSize = 32 * 1024; options.ClientTimeoutInterval = TimeSpan.FromSeconds(60); options.KeepAliveInterval = TimeSpan.FromSeconds(15); }); #endregion #region ── FastEndpoints (moved outside — registered unconditionally below) ─── // AddFastEndpoints + SwaggerDocument are registered after this block // so Swagger works regardless of schedulerEnabled / celitixEnabled flags. #endregion #region ── HttpClient Registrations ──────────────────────────────── // OIDC — accepts any cert (internal / self-signed CA) builder.Services.AddHttpClient("oidc") .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }); builder.Services.AddHttpClient(); builder.Services.AddHttpClient("WhatsApp", client => { client.DefaultRequestHeaders.Accept.Add( new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); }); builder.Services.AddHttpClient("sysjob", client => { client.Timeout = TimeSpan.FromSeconds(120); }); // EIP Direct-Action internal callback — GenericApiDirectActionHandler builds the full URL // from MDIRECTACTION.SERVICEBASEURL + MDIRECTACTIONDETAIL.APIENDPOINT (DB-driven, per tenant). // No BaseAddress here — same pattern as wip-replay; the URL comes entirely from the database. builder.Services.AddHttpClient("gb5-internal", client => { client.Timeout = TimeSpan.FromSeconds(30); }); // AIExtractNodeExecutor — calls the external Enterprise AI engine (AI-Enterprise-v1.0). // No BaseAddress — AIExtractOptions.EngineBaseUrl builds the full URL per call, same pattern // as gb5-internal/eip-internal above. builder.Services.AddHttpClient("ai-enterprise", client => { client.Timeout = TimeSpan.FromSeconds(120); }); // EIP CALL_API and EIPRestActionHandler — loopback calls to GB5 services via YARP gateway. // URL is resolved at runtime from loginDTO.BaseUri + ServicePrefix/BASEURL + PathTemplate/EndpointPath. // BaseAddress = ServiceBaseUrl so relative paths (/fws/...) resolve correctly when // loginDTO.BaseUri is empty (AppChat test tool, Postman without BaseUri in Login header). builder.Services.AddHttpClient("eip-internal", client => { var svcBase = builder.Configuration["ServiceBaseUrl"] ?? "http://localhost:5000"; client.BaseAddress = new Uri(svcBase.TrimEnd('/') + "/"); client.Timeout = TimeSpan.FromSeconds(30); }); // Portlet/Dashboard Phase 5 Entitlement gating — Entitlement/EntitlementSL folds into // PlatformHost, a SEPARATE process from this Framework host (no shared DI container), // so IEntitlementService cannot be constructor-injected here. Same loopback-via-YARP- // gateway pattern as "eip-internal" above; only works where a real gateway routes // /lic/* to wherever Entitlement/PlatformHost actually lives (NOT true on GB5DEMO, // which has no gateway merging its bare per-module Dapr ports). builder.Services.AddHttpClient("entitlement-internal", client => { var svcBase = builder.Configuration["ServiceBaseUrl"] ?? "http://localhost:5000"; client.BaseAddress = new Uri(svcBase.TrimEnd('/') + "/"); client.Timeout = TimeSpan.FromSeconds(10); }); // GopDocumentIntakeBLL — loopback into EAIAdmin's POST /EAIAdmin/ExecuteCapability (hosted by // PlatformHost, a separate process from this Framework host). Same "eip-internal"/ // "entitlement-internal" loopback-via-gateway pattern and topology caveat: only reachable // where a real gateway routes /EAIAdmin/* to wherever PlatformHost actually lives. builder.Services.AddHttpClient("eaiadmin-internal", client => { var svcBase = builder.Configuration["ServiceBaseUrl"] ?? "http://localhost:5000"; client.BaseAddress = new Uri(svcBase.TrimEnd('/') + "/"); client.Timeout = TimeSpan.FromSeconds(120); }); // ApiCallNodeExecutor targets (e.g. Recruitment's Candidate/SaveCandidate, hosted by // HRFinanceHost, a separate process from this Framework host) build an absolute request URL // from the GOP snapshot step's own ResolvedEndpoint + RelativePath columns (never from this // client's BaseAddress — ApiCallNodeExecutor always passes an absolute URI). This // registration only supplies the timeout; the base URL that snapshot rows actually use lives // in MGOPTARGETOPERATION.BASEURL, seeded to the same "ServiceBaseUrl" placeholder value — // an admin must update that row (via the GOP Target Operation admin screen) to the real // per-environment HRFinanceHost address before an ApiCall step targeting it can succeed. builder.Services.AddHttpClient("hrfinance-internal", client => { client.Timeout = TimeSpan.FromSeconds(60); }); // PromotionExchange — a separate module/process entirely (GB5Solution/PromotionExchange), // reached only over HTTP with an X-Api-Key header, never a direct project reference. BaseAddress // is set per-call in PromotionExchangeClient (needs PromotionExchange:BaseUrl at call time, same // as the "jobengine" pattern), so no BaseAddress configured here. builder.Services.AddHttpClient("promotionexchange", client => { client.Timeout = TimeSpan.FromSeconds(60); }); // SqlWorkbench (GB5Solution/SqlWorkbench, folds into PlatformHost — a separate process // from this Framework host) — consumed by VersionSyncBLL/SqlWorkbenchVersionClient to // resolve a ClientDatabaseId's currently-applied version after a change request is // applied. Same Integration:SqlWorkbenchBaseUrl config key EntitlementModule uses for // its own SqlWorkbench client, for the same reason: a real configured address, not the // ServiceBaseUrl-loopback-via-gateway pattern above (that pattern is for routing back // into this same host's own gateway-fronted modules, not into PlatformHost). builder.Services.AddHttpClient("SqlWorkbench", client => { var baseUrl = builder.Configuration["Integration:SqlWorkbenchBaseUrl"]; if (!string.IsNullOrWhiteSpace(baseUrl)) client.BaseAddress = new Uri(baseUrl); client.Timeout = TimeSpan.FromSeconds(30); }); // Promotion bridge — reaches a cross-host entity's own PromotionBridge endpoints (e.g. TMS's // TrainingProgramme adapter on HRFinanceHost). One generic named client shared by every // RemoteHttpPromotionAdapter instance; each instance resolves its own module's BaseUrl from // config per-call (Promotion:RemoteModules:{ModuleCode}:BaseUrl), same as the "jobengine"/ // "promotionexchange" pattern above — no BaseAddress here. builder.Services.AddHttpClient("promotion-remote", client => { client.Timeout = TimeSpan.FromSeconds(60); }); #endregion #region ── Assembly Scan — Auto-register BLL + DAL ───────────────── // Scans FrameworkBLL, FrameworkDAL, and GB5Shared assemblies and registers all // classes that implement an interface with Scoped lifetime. // IHostedService types are excluded — they must be registered explicitly // below to control singleton/hosted lifecycle correctly. // // GB5Shared added 2026-08-02 — a prior refactor moved several BLL/DAL classes // (ActionProcessor/EventActionRunDAL, IceMap, FileUpload/AttachmentDAL, // PushNotification, DirectAction, Attachment, etc.) from FrameworkBLL/FrameworkDAL // into GB5Shared without adding it to this scan, which crashed gb5.service on // startup (DI resolution failure for IEventActionRunDAL via PlaygroundBLL — // FastEndpoints validates every endpoint's DI graph eagerly at MapFastEndpoints // time, so ANY such gap is fatal at boot, not just on first request to that // endpoint). Types already explicitly registered above by concrete class (no // interface pairing, e.g. HybridCacheService/AutoNumber) are unaffected — the // scan only maps AsImplementedInterfaces(). Any GB5Shared type that legitimately // needs a non-Scoped lifetime (the one known case being QualifierCacheInvalidator, // Singleton) must keep an explicit override registered AFTER this scan, same // pattern as below — a scan-added Scoped registration would otherwise shadow it. var bllAsm = Assembly.Load("FrameworkBLL"); var dalAsm = Assembly.Load("FrameworkDAL"); var sharedAsm = Assembly.Load("GB5Shared"); builder.Services.Scan(scan => scan.FromAssemblies(bllAsm, dalAsm, sharedAsm) .AddClasses(classes => classes .Where(type => !typeof(IHostedService).IsAssignableFrom(type) && !typeof(Exception).IsAssignableFrom(type) && !typeof(GB5Shared.Attachment.ITokenResolver).IsAssignableFrom(type) // GB5Shared.Vault types are already registered explicitly by // AddGB5Vault() (line ~558) with specific lifetimes (IVaultClient/ // IVaultService/IVaultAuthMethodProvider are Singleton). Letting the // scan also register them (as Scoped, via AsImplementedInterfaces()) // double-registers IVaultAuthMethodProvider, and the Vault singleton // factory's sp.GetServices() call then fails // with "Cannot resolve scoped service ... from root provider" because // the resulting IEnumerable includes a Scoped entry. Discovered // 2026-08-03. && !(type.Namespace?.StartsWith("GB5Shared.Vault", StringComparison.Ordinal) ?? false) // GB5Shared.Auth.Jwt types (IJwtSigningKeyResolver/IJwtAccessTokenIssuer) are // already registered explicitly by AddGB5JwtIssuer() (line ~609) as Singleton. // Letting the scan also register JwtSigningKeyResolver as Scoped via // AsImplementedInterfaces() shadows that registration for lookups, so the // Singleton JwtAccessTokenIssuer ends up depending on a Scoped // IJwtSigningKeyResolver and ValidateOnBuild fails with "Cannot consume // scoped service ... from singleton". Discovered 2026-08-24. && !(type.Namespace?.StartsWith("GB5Shared.Auth.Jwt", StringComparison.Ordinal) ?? false) // RemoteHttpPromotionAdapter is constructed manually via a keyed factory // delegate below (AddKeyedScoped("TRAININGPROGRAMME", (sp, _) => new ...)) // because it takes plain string ctor args (entityTypeCode/moduleConfigKey) // that reflection-based DI can never resolve. Letting the scan also // register it as an unkeyed Scoped IPromotableEntity crashes ValidateOnBuild // with "Unable to resolve service for type 'System.String'". Discovered 2026-08-24. && type != typeof(FrameworkBLL.Promotion.Adapters.RemoteHttpPromotionAdapter) // GB5Shared.Deployment.GB5Environment (IGB5Environment) is already // registered explicitly by AddGB5Environment() (line ~650) as Singleton — // it's resolved from app.Services (the root provider) right after // builder.Build() to log the deployment tier. Letting the scan also // register it as Scoped via AsImplementedInterfaces() shadows that // Singleton registration, so the root-provider resolve then fails with // "Cannot resolve scoped service 'IGB5Environment' from root provider." && !(type.Namespace?.StartsWith("GB5Shared.Deployment", StringComparison.Ordinal) ?? false) // GB5Shared.Swagger's NSwag IOperationProcessor implementations // (ModuleTagProcessor, ModuleScopedProcessor) take runtime-supplied // constructor args (Dictionary, string) that plain // reflection-based DI can never resolve — they're constructed // manually per-document via AddSwaggerDocument(), never via the // container. Discovered 2026-08-02: adding GB5Shared to this scan // crashed gb5.service a second time at NSwag's UseOpenApi() startup // ("Unable to resolve service for type 'System.String'") the moment // ModuleScopedProcessor got auto-registered as IOperationProcessor. && !typeof(NSwag.Generation.Processors.IOperationProcessor).IsAssignableFrom(type) // GB5Shared.Storage.S3StorageProvider implements IStorageProvider but // takes a raw IAmazonS3 (never registered — no default bucket/region/ // credentials at the container level) and a plain string bucketName. // It's built manually per-tenant by AttachmentStorageResolver from that // tenant's own MDBLEVELSETTING row, never resolved via DI. Letting the // scan auto-register it as Scoped IStorageProvider crashes ValidateOnBuild // with "Unable to resolve service for type 'Amazon.S3.IAmazonS3'". // Discovered 2026-08-12. && type != typeof(GB5Shared.Storage.S3StorageProvider) && type.GetMethod("$") == null)) // exclude C# records (IEquatable confuses DI) .AsImplementedInterfaces() .WithScopedLifetime()); #endregion #region ── QualifierCacheInvalidator — Singleton override ─────────────────── // QualifierCacheInvalidator holds a ConcurrentDictionary tracking registered // cache keys per tenant across ALL requests. It MUST be Singleton. // The assembly scan above registers it as Scoped via IQualifierCacheInvalidator; // these explicit Singleton registrations override that so the same instance is // used across requests. QualifierFacadeImpl injects the concrete class directly. builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); #endregion // ═══════════════════════════════════════════════════════════════ // R&D BLOCK — Scheduler Task Generator // ▸ Guarded by schedulerEnabled flag // ▸ When Enable=N: Quartz, RabbitMQ, SchedulerTaskManager all OFF // ▸ Nothing outside this block is affected by the flag // ═══════════════════════════════════════════════════════════════ if (schedulerEnabled) { #region ── Scheduler — DAL + BLL ─────────────────────────────── // Explicitly register scheduler DAL and BLL here. // Assembly scan above covers these too but explicit registration // makes the scheduler dependency graph clear and intentional. builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion #region ── Scheduler — RabbitMQ Publisher ────────────────────── // RabbitMqPublisher implements both IHostedService (for async connection // at startup) and IRabbitMqPublisher (for publish calls from SchedulerTaskManager). // Must be registered as a singleton shared across both interfaces so the // same connection instance is used everywhere. builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // ActionProcessorWorker — subscribes to action-exec-p* queues and // dispatches email / SMS / webhook actions to the appropriate handler. builder.Services.AddHostedService(); // SimpleEmailWorker — dedicated queue "email-direct" → SMTP. No DB/tenant dependency. builder.Services.AddHostedService(); // JobQueueWorkerService — polls TJOBQUEUE and processes claimed items (complete/retry/DLQ). // See FrameworkSL/Jobs/JobQueueWorkerService.cs for why this exists here instead of // in the separate GB5Solution/JobEngine microservice. builder.Services.AddHostedService(); #endregion #region ── Scheduler — Quartz Jobs ───────────────────────────── // SchedulerJobExecutorQuartzJob → per-job native trigger, registered // dynamically by QuartzSyncService // (replaces the old generic 30s poller) // SysJobExecutorQuartzJob → executes system-level background jobs // OutboxPublishQuartzJob → publishes TOUTBOX rows to Dapr pubsub // ActionOutboxDispatcherQuartzJob → publishes TACTIONOUTBOX rows to RabbitMQ StartupLog("Registering Scheduler Quartz Jobs..."); // IEventActionRunDAL/IActionOutboxDAL now registered unconditionally above (needed by // PlaygroundBLL/EventSubBLL/etc. regardless of Scheduler mode). builder.Services.AddTransient(); // Scoped, not Singleton — QuartzSyncService directly consumes scoped services // (IApplicationConnection, IOptionsSnapshot<>, ISchedulerTaskDAL). Singleton-lifetime // callers (QuartzBootstrapHostedService) resolve it via their own scope instead of // direct constructor injection. builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); // Delivery for Track A — driven straight off TJOBEXECUTION, no TJOBQUEUE hop. // SchedulerJobExecutorQuartzJob only records the job as Pending; SchedulerExecutionPoller // atomically claims due rows and SchedulerExecutionDeliveryService makes the HTTP call, // with retry/backoff/DLQ decided entirely by SchedulerTaskServiceBLL.ApplyRetryOrDeadLetterAsync // against TJOBEXECUTION.RETRYNUMBER — see SchedulerExecutionPoller's header comment for why // this replaced the old TJOBQUEUE-based design (SchedulerJobQueueProcessor/SchedulerWebServiceCallHandler). builder.Services.AddScoped(); builder.Services.AddHostedService(); // TJOBQUEUE's job as of 2026-08-20: purely the action-dispatch queue for scheduler // jobs that have a configured MACTION. SchedulerExecutionDeliveryService enqueues a // SCHEDULER_ACTION_EVENT here on job success; SchedulerActionEventQueueProcessor // (a BaseJobQueueProcessor, same atomic-claim/retry/DLQ infra as every other // GB5Shared/QueueReader consumer) claims it and SchedulerActionEventPublishHandler // Dapr-publishes it — feeding the existing EventActionSubscribeController/EventSubBLL // pipeline, not a new one. builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddQuartz(q => { // Must match the QRTZ_LOCKS seed rows (SCHED_NAME='GB5Scheduler') created by // DB/Migrations/20260814_JobEngine_TrackA_QuartzNative_SqlServer.sql. q.SchedulerId = FrameworkBLL.SchedulerTaskGenerator.QuartzSyncService.SchedulerName; // Persistent ADO job store — SchedulerJobExecutorQuartzJob's per-job triggers // (registered dynamically by QuartzSyncService, one per active MJOBDEFINE row) // survive app restarts here instead of living only in memory. Uses the QRTZ_* // tables already created inside unisoftgb4 by the 20260726/20260814 migrations. // // NOTE: this points at ONE physical database (SchedulerRun:QuartzStoreDatabaseName), // not a per-tenant store — Quartz's ADO store is inherently single-database per // scheduler instance. This is fine: QRTZ_* rows are just trigger/schedule metadata // (JobId/TenantId/DatabaseName/ConnectionName live in each job's JobDataMap), not // tenant business data, so co-locating them in one tenant's physical DB works // correctly for any tenant. A dedicated shared/system DB would be architecturally // cleaner for a large multi-tenant deployment, but is not required for correctness. var quartzServer = builder.Configuration["SchedulerRun:Server"]; var quartzDb = builder.Configuration["SchedulerRun:QuartzStoreDatabaseName"]; var quartzUser = builder.Configuration["SchedulerRun:UserName"]; var quartzPass = builder.Configuration["SchedulerRun:Password"]; if (!string.IsNullOrWhiteSpace(quartzServer) && !string.IsNullOrWhiteSpace(quartzDb)) { q.UsePersistentStore(s => { s.UseProperties = true; s.UseSqlServer(sql => sql.ConnectionString = $"Data Source={quartzServer};Initial Catalog={quartzDb};User Id={quartzUser};Password={quartzPass};Pooling=True;Encrypt=False;TrustServerCertificate=True"); s.UseSystemTextJsonSerializer(); }); } else { Console.WriteLine("WARNING: SchedulerRun:Server/QuartzStoreDatabaseName not configured — Quartz falling back to in-memory RAMJobStore (triggers will NOT survive a restart)."); } // ── SysJobExecutorQuartzJob — every 30s ───────────────── var sysJobKey = new JobKey("SysJobExecutorJob"); q.AddJob(o => o.WithIdentity(sysJobKey)); q.AddTrigger(t => t .ForJob(sysJobKey) .WithIdentity("SysJobExecutorJob-trigger") .StartNow() .WithSimpleSchedule(x => x .WithIntervalInSeconds(30) .RepeatForever())); // ── OutboxPublishQuartzJob — every 30s ────────────────── var outboxJobKey = new JobKey("OutboxPublishJob"); q.AddJob(o => o.WithIdentity(outboxJobKey)); q.AddTrigger(t => t .ForJob(outboxJobKey) .WithIdentity("OutboxPublishJob-trigger") .StartNow() .WithSimpleSchedule(x => x .WithIntervalInSeconds(30) .RepeatForever())); // ── ActionOutboxDispatcherQuartzJob — every 30s ────────── var actionOutboxJobKey = new JobKey("ActionOutboxDispatcherJob"); q.AddJob(o => o.WithIdentity(actionOutboxJobKey)); q.AddTrigger(t => t .ForJob(actionOutboxJobKey) .WithIdentity("ActionOutboxDispatcherJob-trigger") .StartNow() .WithSimpleSchedule(x => x .WithIntervalInSeconds(30) .RepeatForever())); // ── EventLogArchiveQuartzJob — nightly 2 AM UTC ────────── // Runs regardless of schedulerEnabled flag — data retention is critical path. var archiveJobKey = new JobKey("EventLogArchiveJob"); q.AddJob( o => o.WithIdentity(archiveJobKey)); q.AddTrigger(t => t .ForJob(archiveJobKey) .WithIdentity("EventLogArchiveJob-trigger") .WithCronSchedule("0 0 2 * * ?")); // 2 AM UTC daily // ── PromotionExchangeIngestQuartzJob — every 5 minutes ─── // Tier 2 (Exchange) "check my pending packages" poller — see the plan's Transport // section. Best-effort by design (Exchange may be unreachable; Tier 1 manual carry // still works regardless), so a slower interval than Outbox/SysJob is fine. var promotionExchangeJobKey = new JobKey("PromotionExchangeIngestJob"); q.AddJob( o => o.WithIdentity(promotionExchangeJobKey)); q.AddTrigger(t => t .ForJob(promotionExchangeJobKey) .WithIdentity("PromotionExchangeIngestJob-trigger") .StartNow() .WithSimpleSchedule(x => x .WithIntervalInSeconds(300) .RepeatForever())); }); // ✅ AddQuartzHostedService is REQUIRED — AddQuartz() alone registers // jobs/triggers but does NOT start the scheduler engine. // WaitForJobsToComplete=true ensures graceful shutdown waits for // any in-progress job to finish before the host stops. builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); // One-time startup migration: registers a native Quartz trigger for every // currently-active MJOBDEFINE row across all tenants. Must be registered // AFTER AddQuartzHostedService so IScheduler is already started when this // runs. Guarded by its own flag — keep disabled until a synthetic test job // has been verified to fire correctly exactly once (see rollout plan). builder.Services.AddHostedService(); StartupLog("Scheduler Quartz Jobs registered."); #endregion #region ── Scheduler — MassTransit + RabbitMQ Consumer ───────── // ✅ MassTransit is inside the schedulerEnabled guard. // Previously it was outside this guard — when schedulerEnabled=N // and celitixEnabled=Y, MassTransit still tried to connect to RabbitMQ. builder.Services.AddMassTransit(x => { x.UsingRabbitMq((ctx, cfg) => { var rabbit = builder.Configuration.GetSection("RabbitMQ"); cfg.Host( rabbit["Host"] ?? "localhost", rabbit["VirtualHost"] ?? "/", h => { h.Username(rabbit["UserName"] ?? "guest"); h.Password(rabbit["Password"] ?? "guest"); }); // Consumer endpoint — receives JobExecutionId from queue // and routes to ActionType handlers cfg.ReceiveEndpoint("scheduler-queue", e => { e.UseRawJsonSerializer(); e.Handler(c => { Serilog.Log.Information( "Scheduler job received | JobId={JobId}", c.Message.JobId); return Task.CompletedTask; }); }); }); }); #endregion StartupLog("Scheduler block fully registered."); } // end if (schedulerEnabled) else { #region ── Outbox Publisher (runs even when scheduler is off) ─── // OutboxPublishQuartzJob handles reliable outbox messaging and // must run independently of the scheduler feature flag. StartupLog("Scheduler disabled — registering Outbox Publisher + Archive Job + SysJobExecutor only."); builder.Services.AddTransient(); builder.Services.AddTransient(); // SysJobExecutorJob executes TSYSJOB rows queued by the report-viewer "Job" button — // it has no relationship to Track A's MJOBDEFINE/TJOBEXECUTION scheduling (the thing // schedulerEnabled actually gates per this flag's own doc comment above), so it was a // bug for it to sit inside this guard at all. It was previously bundled in here and // never ran on any environment with SchedulerRun:Enable != Y — carved out to match how // OutboxPublishJob/EventLogArchiveJob are already treated below. builder.Services.AddTransient(); builder.Services.AddQuartz(q => { var outboxJobKey = new JobKey("OutboxPublishJob"); q.AddJob(o => o.WithIdentity(outboxJobKey)); q.AddTrigger(t => t .ForJob(outboxJobKey) .WithIdentity("OutboxPublishJob-trigger") .StartNow() .WithSimpleSchedule(x => x .WithIntervalInSeconds(30) .RepeatForever())); // Archive job always runs — data retention is critical path var archiveJobKey = new JobKey("EventLogArchiveJob"); q.AddJob( o => o.WithIdentity(archiveJobKey)); q.AddTrigger(t => t .ForJob(archiveJobKey) .WithIdentity("EventLogArchiveJob-trigger") .WithCronSchedule("0 0 2 * * ?")); // 2 AM UTC daily // SysJobExecutorJob — every 30s, same schedule as the schedulerEnabled branch above var sysJobKey = new JobKey("SysJobExecutorJob"); q.AddJob(o => o.WithIdentity(sysJobKey)); q.AddTrigger(t => t .ForJob(sysJobKey) .WithIdentity("SysJobExecutorJob-trigger") .StartNow() .WithSimpleSchedule(x => x .WithIntervalInSeconds(30) .RepeatForever())); }); builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); StartupLog("Outbox Publisher + Archive Job registered."); #endregion } // ═══════════════════════════════════════════════════════════════ // R&D BLOCK — MessageHub Platform Adapters // ▸ Multi-platform outbound messaging (Email, SMS, WhatsApp, Teams, // Slack, Telegram, Push Notification, Celitix) // ▸ celitixEnabled flag controls Celitix platform activation // ▸ All other platform adapters always register when this outer // block (schedulerEnabled || celitixEnabled) is active // ═══════════════════════════════════════════════════════════════ #region ── MessageHub — Settings ─────────────────────────────────── builder.Services.Configure( builder.Configuration.GetSection("MessageHubSettings")); builder.Services.Configure( builder.Configuration.GetSection("Keycloak")); builder.Services.Configure( builder.Configuration.GetSection("MessageHubSettings:Celitix")); builder.Services.Configure( builder.Configuration.GetSection("MessageHubSettings:Telegram")); // Override Enable flag at runtime from the feature boolean builder.Services.PostConfigure(cfg => { cfg.Enable = celitixEnabled ? "Y" : "N"; }); #endregion #region ── MessageHub — Conversation Config ───────────────────────── // Loads MessageHubConversation.json at startup. // Falls back gracefully if file is missing or malformed — never throws. builder.Services.AddSingleton(sp => { var logger = sp.GetRequiredService>(); var filePath = Path.Combine(Directory.GetCurrentDirectory(), "MessageHubConversation.json"); MessageHubConversationDTO Fallback() => new MessageHubConversationDTO { Steps = new List(), Triggers = new List(), DefaultFallbackMessage = "Sorry, something went wrong. Please try again." }; if (!File.Exists(filePath)) { logger.LogWarning( "MessageHubConversation.json not found at {FilePath}. Using empty default.", filePath); return Fallback(); } try { var json = File.ReadAllText(filePath); return Newtonsoft.Json.JsonConvert .DeserializeObject(json) ?? Fallback(); } catch (Exception ex) { logger.LogError(ex, "Failed to parse MessageHubConversation.json. Using empty default."); return Fallback(); } }); #endregion #region ── MessageHub — Platform Adapters ────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion // ═══════════════════════════════════════════════════════════════ // R&D BLOCK — EIP Conversation Engine // ▸ Enterprise Integration Pattern conversation routing // ▸ Flow, Routing, Capability, and Action Execution layers // ▸ Tenant context resolved per request via ITenantContext // ▸ All EIP services depend on MessageHub platform adapters above // ═══════════════════════════════════════════════════════════════ #region ── EIP — Tenant Context ──────────────────────────────────── builder.Services.AddScoped(); #endregion #region ── EIP — Flow Layer ───────────────────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion #region ── EIP — Routing Layer ────────────────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion #region ── EIP — Capability Layer ─────────────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion #region ── EIP — Action Execution ─────────────────────────────────── builder.Services.AddScoped(); #endregion #region ── EIP — Conversation Engine ──────────────────────────────── builder.Services.AddScoped(); builder.Services.AddLogging(); builder.Services.AddScoped(); #endregion } // end if (schedulerEnabled || celitixEnabled || eipEnabled) // ── FastEndpoints + Swagger — always registered regardless of feature flags ─── // Must be outside the scheduler/celitix guard so Swagger works even when // both flags are N (e.g. lightweight health-check / API-only deployments). builder.Services.AddFastEndpoints(o => { // Scan FrameworkSL only. // GB5Shared is a pre-compiled DLL reference (not a project reference). // NSwag tries to reflect ALL types from every scanned assembly during swagger.json // generation. GB5Shared contains BaseEndpoint, Tracer, DaprClient and other // complex types whose dependent assemblies may not be fully loaded at reflection // time → ReflectionTypeLoadException → swagger.json returns 500. // All 577 concrete endpoint classes live in FrameworkSL; GB5Shared only has the // abstract BaseEndpoint base class which FastEndpoints skips anyway. // FrameworkBLL/FrameworkDAL are also excluded — they contain Quartz + MassTransit // types that NSwag cannot generate schemas for. o.Assemblies = new[] { Assembly.Load("FrameworkSL") }; }); builder.Services.SwaggerDocument(o => { o.DocumentSettings = s => { s.Title = "GB5 Framework API"; s.Version = "v1"; s.Description = "GB5 Framework Service"; s.SchemaSettings.GenerateAbstractProperties = false; s.SchemaSettings.IgnoreObsoleteProperties = true; }; o.EnableJWTBearerAuth = false; o.ShortSchemaNames = true; }); // ── Swashbuckle — MVC Controllers swagger (separate doc at /swagger-mvc/v1/swagger.json) ── // FastEndpoints.Swagger (NSwag) covers FastEndpoints routes above. // Swashbuckle covers MVC controllers (Dapr, KeyCloak, Menu, Role, GOP, etc.). // AddEndpointsApiExplorer lets Swashbuckle discover minimal-API routes too if needed. builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo { Title = "GB5 Framework Controllers API", Version = "v1", Description = "GB5 Framework Service — MVC Controllers (Dapr, KeyCloak, Menu, Role, GOP, Scheduler, etc.)" }); c.DocInclusionPredicate((_, _) => true); // Suppress duplicate action errors from multiple routes on the same method c.ResolveConflictingActions(descriptions => descriptions.First()); }); // ── Forwarded-headers trust — always active regardless of feature flags ────── // Clears KnownNetworks/KnownProxies so the app trusts X-Forwarded-For and // X-Forwarded-Proto from any upstream proxy (Nginx, Traefik, cloud LB, Dapr). // UseForwardedHeaders() is called FIRST in the pipeline so all downstream // middleware (routing, CORS, session) sees the real IP and protocol. builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); // ── Health check — responds to load-balancer probes at /healthz ───────────── builder.Services.AddHealthChecks(); // ═══════════════════════════════════════════════════════════════════ // Build Application // ═══════════════════════════════════════════════════════════════════ GB5DapperTypeHandlers.Register(); StartupLog("Service registration complete — calling builder.Build() (DI graph validation happens here)"); var app = builder.Build(); StartupLog("builder.Build() complete — configuring middleware pipeline"); // Log the resolved deployment tier loudly — a misconfigured box silently falling through to // Live's un-suffixed connection names is the one real risk this abstraction doesn't otherwise // guard against (see GB5Shared/Deployment). var gb5Environment = app.Services.GetRequiredService(); StartupLog($"GB5 deployment tier: {gb5Environment.TierCode}"); // ═══════════════════════════════════════════════════════════════════ // Middleware Pipeline // Order follows ASP.NET Core recommended sequence. // ═══════════════════════════════════════════════════════════════════ // 0. Gateway prefix forwarding — when reached through GB5Build under /fws/, sets PathBase // from X-GB5-Gateway-Prefix so Swagger's self-referencing links resolve correctly both // via the gateway and via direct port access. Must run before everything else. app.UseGatewayPrefixForwarding(); // 1. Forwarded-headers — MUST be first so routing, CORS, and session // all see the real client IP and protocol (not the proxy's address). app.UseForwardedHeaders(); // 1a. Security headers — applied to every response before routing resolves. app.Use(async (ctx, next) => { ctx.Response.Headers.Append("X-Content-Type-Options", "nosniff"); ctx.Response.Headers.Append("X-Frame-Options", "DENY"); ctx.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); ctx.Response.Headers.Append("X-XSS-Protection", "0"); await next(); }); // 2. Routing — explicit call required before endpoint middleware executes. app.UseRouting(); // 3. Tracing enrichment — adds correlation ID, client IP, Dapr source, WIP marker // to the active OTel span from request headers. app.UseMiddleware(); // 4. Authentication/Authorization — multi-tenant Keycloak JWT Bearer (GB5 Repo-Wide // Authentication Hardening plan, Phase 5 pilot). Additive: populates HttpContext.User only // when a caller actually sends a valid Bearer token for a Keycloak-mode tenant; // BaseEndPoint.GetLoginDTOFromRequestAsync's dual-mode bridge is what actually consumes this — // nothing here enforces anything on its own (no [Authorize] attributes added anywhere yet). app.UseAuthentication(); app.UseAuthorization(); // 5. Session app.UseSession(); // 5a. Session heartbeat — updates MSESSIONSTORE.LASTLOGINUSEDTIME in the GB5 // system DB on every authenticated API call. Non-blocking: DB errors are // caught and logged; the business request always continues. // Skips: unauthenticated requests, Dapr routes, health checks, swagger. app.UseMiddleware(); // 5b. EIP Webhook Signature Validation — body-buffering + per-channel HMAC check. // Must run BEFORE FastEndpoints so the raw body is still readable. // Config: EIP:WebhookSecrets:WhatsApp / EIP:WebhookStrictValidation app.UseMiddleware(); // 5c. Keycloak admin-API M2M auth — every /Keycloak/* endpoint was AllowAnonymous() with zero // auth (found 2026-09-09); now gated by a Vault-backed shared secret, same UseWhen // exact-prefix convention as PlatformHost's own Partner/KMS/EAIAdmin ApiKeyAuthMiddleware // blocks. See KeycloakApiKeyAuthMiddleware's own doc comment for the full rationale. app.UseWhen( ctx => ctx.Request.Path.StartsWithSegments("/Keycloak"), branch => branch.UseMiddleware()); // 6. FastEndpoints (consistent JSON serialisation) app.UseFastEndpoints(c => { c.Serializer.Options.PropertyNamingPolicy = null; c.Serializer.Options.DictionaryKeyPolicy = null; c.Serializer.Options.AddGB5Converters(); }); // 7. Dapr CloudEvents unwrapper — must be before MapControllers so Dapr // pub/sub controllers can deserialize CloudEvent envelopes. // MapSubscribeHandler() is intentionally removed — DaprSubscribeController // serves GET /dapr/subscribe dynamically from MEVENTTYPE (system DB). app.UseCloudEvents(); // 8. MVC Controllers (includes Dapr event/pubsub/sysjob controllers) app.MapControllers(); // 9. GOP SignalR Hub // Group: "gop:client:{clientId}" — all tenant users share one group. // Clients call ReceiveExecutionUpdate / ReceiveDLQUpdate on their side. // Requires AddSignalR() (registered in the SignalR region above). app.MapHub("/hubs/gop-execution"); // 9b. DataSync SignalR Hub // Group: "datasync:{runId}" — client subscribes per run via JoinRunGroup(runId). // BLL pushes ReceiveProgress (per chunk) and ReceiveRunCompleted (on finish). app.MapHub("/hubs/datasync"); // 9c. EIPChat SignalR Hub — AppChat channel for in-app EIP bot conversations. // Group: "appchat:user:{userId}:client:{clientId}" — identity-scoped per user. // Client calls SendMessage(message, flowCode); bot response pushed via ReceiveBotMessage. app.MapHub("/hubs/eip-chat"); // 9d. SysJob SignalR Hub — real-time progress for user-submitted batch jobs (TSYSJOB). // Group: "sysjob:client:{clientId}" — tenant-scoped. // Pushes JobStarted / JobCompleted / JobRetrying / JobFailed as SysJobExecutorQuartzJob runs. app.MapHub("/hubs/sysjob"); // 9e. JobEngine SignalR Hub — real-time progress for MJOBDEFINE/TSCHEDULER (Track A) jobs. // Group: "jobengine:client:{clientId}" — tenant-scoped. // Pushes ReceiveJobStarted/ReceiveJobDispatched (SchedulerJobExecutorQuartzJob) and // ReceiveJobCompleted/ReceiveJobFailed (ActionProcessorWorker.TryFinalizeJobExecutionAsync). app.MapHub("/hubs/job-engine"); // 9f. Dashboard SignalR Hub — pushes a lightweight "this dataset changed for OU X" signal so an // open dashboard can refetch a widget instead of only refreshing on a manual reload or a fixed // poll interval. Group: "dashboard:client:{clientId}" — tenant-scoped. app.MapHub("/hubs/dashboard"); // 10. Health / liveness probes app.MapGet("/", () => "Hello from GB5Framework in .NET 9 API!"); app.MapHealthChecks("/healthz"); // 11. Swagger UI — placed AFTER all endpoints are mapped so NSwag can // correctly enumerate every FastEndpoints route and MVC controller. // Available at /GB5Documentation/fws app.UseOpenApi(); app.UseSwaggerUi(o => { o.Path = "/GB5Documentation"; o.DocumentPath = "/swagger/{documentName}/swagger.json"; // Dynamically prefixes internal Swagger UI links (including the swagger.json fetch URL) // with the gateway's alias (PathBase set above from X-GB5-Gateway-Prefix) when reached via // the gateway. Direct port access has no PathBase, so links stay unprefixed. o.TransformToExternalPath = (internalUiRoute, request) => request.PathBase.HasValue ? request.PathBase.Value + internalUiRoute : internalUiRoute; }); // 12. Swashbuckle — MVC Controllers swagger document. // Served at /swagger-mvc/v1/swagger.json (different path from NSwag above). // GB5Build gateway fetches /fws/swagger-mvc/v1/swagger.json → YARP strips /fws // → this handler sees /swagger-mvc/v1/swagger.json. // No SwaggerUI here — GB5Build aggregates it in the central hub. app.UseSwagger(c => c.RouteTemplate = "swagger-mvc/{documentName}/swagger.json"); // 13. Swagger diagnostic endpoint — self-fetches /swagger/v1/swagger.json // and returns the body (or error) so we can see what NSwag is actually doing. // Access via gateway: https://api-dev.goodbookserp.in/fws/swagger-diag app.MapGet("/swagger-diag", async (HttpContext ctx, IHttpClientFactory factory) => { ctx.Response.ContentType = "application/json"; var client = factory.CreateClient(); // Call the NSwag swagger endpoint on ourselves var port = ctx.Request.Host.Port ?? 80; var scheme = ctx.Request.Scheme; var url = $"{scheme}://localhost:{port}/swagger/v1/swagger.json"; try { var resp = await client.GetAsync(url); var body = await resp.Content.ReadAsStringAsync(); ctx.Response.StatusCode = (int)resp.StatusCode; if (resp.IsSuccessStatusCode) { await ctx.Response.WriteAsync(body); } else { await ctx.Response.WriteAsJsonAsync(new { status = (int)resp.StatusCode, url = url, body = body }); } } catch (Exception ex) { ctx.Response.StatusCode = 500; await ctx.Response.WriteAsJsonAsync(new { errorType = ex.GetType().FullName, message = ex.Message, url = url }); } }); // ═══════════════════════════════════════════════════════════════════ // Run // ═══════════════════════════════════════════════════════════════════ // ApplicationStarted fires once every IHostedService.StartAsync (Quartz, MassTransit's // bus, etc.) has returned AND Kestrel is bound and accepting connections — this is the // true "time to ready" number a slow-boot report should be measured against, not just // reaching this line (app.Run() below is what actually drives StartAsync to completion). app.Lifetime.ApplicationStarted.Register(() => StartupLog($"Application fully started and listening — total startup time {startupSw.Elapsed}")); StartupLog("Calling app.Run() — starting all IHostedServices + Kestrel now"); Serilog.Log.Information("GB5 Framework started"); var pdfSection = app.Configuration.GetSection("PDFEXPORT"); int renderConcurrency = pdfSection.GetValue("RenderConcurrency") ?? 2; GB5Shared.Export.HybridReport.GlobalBrowser.Configure(renderConcurrency); app.Run();