using System.Reflection; using System.Text; using FastEndpoints; using FastEndpoints.Swagger; using GB5Shared.Auth.Jwt; using GB5Shared.Connection; using GB5Shared.Telemetry; using GB5Shared.Vault; using RecruitmentBLL.Common; using RecruitmentSL.Hubs; using GB5Shared.DateConverter; using static GB5Shared.DateConverter.GB5JsonOptions; using GB5Shared.DTO.Framework.CommonConfig; using GB5Shared.EntityHandler; using GB5Shared.EventLogPublish; using GB5Shared.GB5CommonFunction; using GB5Shared.GB5Library.Qualifier; using GB5Shared.GenerateAutoNumber; using GB5Shared.GOP.Qualifier; using GB5Shared.PubSub.OutBox; using GB5Shared.QueryExecutor; using GB5Shared.Validation; using GB5Shared.WorkFlow.WorkFlowEngine; using GB5Shared.WorkFlow.WorkFlowRunTime; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.IdentityModel.Tokens; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; // ------------------------- // Bootstrap // ------------------------- var builder = WebApplication.CreateBuilder(args); // ── 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}"); // ------------------------- // Console Encoding // ------------------------- Console.OutputEncoding = Encoding.UTF8; // ------------------------- // Dapr & Controllers Setup // ------------------------- builder.Services.AddDaprClient(); builder.Services.AddControllers() .AddDapr() .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = null; options.JsonSerializerOptions.DictionaryKeyPolicy = null; options.JsonSerializerOptions.AddGB5Converters(); }); // ------------------------- // JSON Global (Minimal API / HttpContext) // ------------------------- builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.PropertyNamingPolicy = null; options.SerializerOptions.DictionaryKeyPolicy = null; options.SerializerOptions.AddGB5Converters(); }); // ------------------------- // Caching // ------------------------- #pragma warning disable EXTEXP0018 builder.Services.AddHybridCache(options => { options.DefaultEntryOptions = new HybridCacheEntryOptions(); options.DisableCompression = false; }); #pragma warning restore EXTEXP0018 builder.Services.AddMemoryCache(); builder.Services.AddDistributedMemoryCache(); // ------------------------- // CORS Policy // ------------------------- // ------------------------- // Application Services // ------------------------- builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // ------------------------- // Workflow (required by BaseEntityAppService.ExecuteSaveAsync) // ------------------------- builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // ------------------------- // Generic Entity Save Pipeline (ExecuteSaveAsync) — JobPosition/JobProfile/JobProfileDetail // route their saves through this per CLAUDE.md's Transaction Management pattern. // ------------------------- builder.Services.AddScoped(typeof(BaseEntityAppService<>), typeof(BaseEntityAppService<>)); // ------------------------- // Qualifier Engine (required by BaseEntityAppService.ExecuteSaveAsync) // ------------------------- builder.Services.AddScoped(); builder.Services.AddGB5QualifierEngine(); // ------------------------- // Outbox (required by BaseEntityAppService.ExecuteSaveAsync) // ------------------------- builder.Services.AddScoped(); // ------------------------- // Cache invalidation (used by BLL after every write per CLAUDE.md's caching rules) // ------------------------- builder.Services.AddScoped(); // ------------------------- // Event log publish (used by JobOffer/Interview BLL Delete paths — ExecuteSaveAsync above // already covers Save/Update event publish internally, but there is no equivalent // ExecuteDeleteAsync pipeline, so Delete publishes its own audit-trail event directly) // ------------------------- builder.Services.AddScoped(); // ------------------------- // ECP Integration (Meeting/RecordingTranscript/CommentThread) — cross-host HTTP client + an // "Integration:*BaseUrl" config key, mirroring PAY/PAYSL and DXP/DXPSL Program.cs's identical // named-HttpClient pattern (see RecruitmentBLL.Integration.EcpIntegrationService). ECP is hosted // by a different process (EngagementHost) than Recruitment (HRFinanceHost) — no ProjectReference/ // DI injection of ECP's BLL is possible across hosts. // ------------------------- builder.Services.AddHttpClient(); builder.Services.AddHttpClient("ECPModule", c => { c.BaseAddress = new Uri(builder.Configuration["Integration:ECPModuleBaseUrl"] ?? throw new InvalidOperationException("Integration:ECPModuleBaseUrl not configured in appsettings.json")); }); // ------------------------- // DXP Integration (candidate self-service portal register/login) — cross-host HTTP client into // DXP (hosted by PlatformHost, a different process than Recruitment's HRFinanceHost), same named- // client + "Integration:*BaseUrl" pattern as ECPModule above (see // RecruitmentBLL.Integration.DxpAuthIntegrationService). // ------------------------- builder.Services.AddHttpClient("DXPModule", c => { c.BaseAddress = new Uri(builder.Configuration["Integration:DXPModuleBaseUrl"] ?? throw new InvalidOperationException("Integration:DXPModuleBaseUrl not configured in appsettings.json")); }); // ------------------------- // OKR Integration (Phase 5 recruiter-KPI push into the PERM/OKR KRA-tracking mechanism) — // cross-host HTTP client into OKR (hosted by EngagementHost, a different process than // Recruitment's HRFinanceHost), same named-client + "Integration:*BaseUrl" pattern as // ECPModule/DXPModule above (see RecruitmentBLL.Integration.OkrIntegrationService). // // NOTE: as of this writing OKRSL is NOT wired into EngagementHost's own ProjectReferences (see // that host's .csproj comment: "OKRSL deferred: GoalBLL depends on concrete ObjectiveBLL, not // IObjectiveBLL; DI resolution fails at startup"), so calls through this client will fail with a // connection error until that is fixed. OkrIntegrationService degrades gracefully in that case — // see its class doc comment — so this is safe to wire ahead of that fix. // ------------------------- builder.Services.AddHttpClient("OKRModule", c => { c.BaseAddress = new Uri(builder.Configuration["Integration:OKRModuleBaseUrl"] ?? throw new InvalidOperationException("Integration:OKRModuleBaseUrl not configured in appsettings.json")); }); // ------------------------- // CandidatePortal JWT bearer (Phase 3) — additive, NAMED, non-default scheme. This is the FIRST // AddAuthentication() call anywhere in RecruitmentSL/Program.cs (no GB5 host previously // configured one at all) — deliberately kept non-default: AddAuthentication() with no argument // sets no default scheme, so every existing AllowAnonymous() endpoint in this module keeps // behaving exactly as before. Only the new CandidatePortal/MyApplications and // CandidatePortal/UploadDocument endpoints opt in, via AuthSchemes("CandidatePortal"). // // Signing key/issuer/audience are the exact same three values DXPSL/Program.cs's own // AddJwtBearer configuration validates against (Vault path "dxp/jwt-signing-key", appsettings // "DXPJwt:Issuer"/"DXPJwt:Audience") — see RecruitmentBLL.CandidatePortal.CandidatePortalBLL's // doc comment for why Recruitment itself mints these tokens (rather than relaying DXP's own // Login-issued token) and why two modules sharing one signing key here is a deliberate, reviewed // exception, not an accident (same posture as EntitlementSL's own documented "PartnerM2MJwtBearer" // shared-key scheme). // // The signing key is resolved lazily, on first token validation, via the shared // IJwtSigningKeyResolver (backed by IVaultService's own cache) — not a synchronous Vault fetch at // startup — same justification EntitlementSL/Program.cs's own ClientJwtBearer/PartnerM2MJwtBearer // registration documents: IssuerSigningKeyResolver's delegate signature is inherently // synchronous, so this sync-over-async bridge is confined to a single request thread on a cache // miss, never this host's startup path. // ------------------------- builder.Services.AddGB5Vault(builder.Configuration); builder.Services.AddGB5JwtIssuer(); var candidatePortalJwtIssuer = builder.Configuration["DXPJwt:Issuer"] ?? "GB5-DXP"; var candidatePortalJwtAudience = builder.Configuration["DXPJwt:Audience"] ?? "GB5-DXP-Portal"; builder.Services.AddAuthentication() .AddJwtBearer("CandidatePortal", _ => { }); builder.Services.AddOptions("CandidatePortal") .Configure((options, keyResolver) => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = candidatePortalJwtIssuer, ValidateAudience = true, ValidAudience = candidatePortalJwtAudience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) => new[] { new SymmetricSecurityKey(Encoding.UTF8.GetBytes( keyResolver.GetSigningKeyAsync("dxp/jwt-signing-key", CancellationToken.None) .GetAwaiter().GetResult())) }, ClockSkew = TimeSpan.FromSeconds(30) }; }); builder.Services.AddAuthorization(); // ------------------------- // SignalR — RecruitmentHub (pipeline-stage + interview-scheduling live push) // ------------------------- builder.Services.AddSignalR(options => { options.EnableDetailedErrors = builder.Environment.IsDevelopment(); options.MaximumReceiveMessageSize = 32 * 1024; // 32 KB — raise only if justified options.ClientTimeoutInterval = TimeSpan.FromSeconds(60); options.KeepAliveInterval = TimeSpan.FromSeconds(15); }); // IRecruitmentHubNotifier (RecruitmentBLL.Common) is the BLL-side abstraction over the // IHubContext push — RecruitmentHubNotifier (this project) is the only place allowed to know // about SignalR, per CLAUDE.md's "BLL must never reference ASP.NET types" rule. builder.Services.AddScoped(); builder.Services.Configure( builder.Configuration.GetSection("Gb5SystemDTO")); // ------------------------- // Kestrel // ------------------------- builder.Services.Configure(options => { options.AllowSynchronousIO = true; }); // -- OpenTelemetry (Tracing + Metrics) ---------------------------------------- builder.Services.AddGB5Telemetry(builder.Configuration, "GB5-RECRUITMENT"); // ------------------------- // Assembly Scan for DI // ------------------------- var recruitmentBLLAssembly = Assembly.Load("RecruitmentBLL"); var recruitmentDALAssembly = Assembly.Load("RecruitmentDAL"); builder.Services.Scan(scan => scan .FromAssemblies(recruitmentBLLAssembly, recruitmentDALAssembly) .AddClasses(c => c.Where(t => !t.IsAbstract && !t.IsInterface && (t.Namespace == null || !t.Namespace.Contains(".DTO")))) .AsImplementedInterfaces() .WithScopedLifetime()); // ------------------------- // FastEndpoints // ------------------------- var endpointAssemblies = new[] { Assembly.Load("RecruitmentSL"), Assembly.Load("RecruitmentBLL"), Assembly.Load("RecruitmentDAL"), Assembly.Load("GB5Shared") }; builder.Services.AddFastEndpoints(o => { o.Assemblies = endpointAssemblies; }); // ------------------------- // Swagger // ------------------------- builder.Services.SwaggerDocument(o => { o.DocumentSettings = s => { s.Title = "GB5 Recruitment API"; s.Version = "v1"; s.Description = "GB5 Recruitment Microservice"; }; o.EnableJWTBearerAuth = false; o.ShortSchemaNames = true; }); // ------------------------- // Build App // ------------------------- var app = builder.Build(); // ------------------------- // Middleware // ------------------------- app.UseCloudEvents(); // FIX: Was app.MapFastEndpoints() � wrong method, no serializer config app.UseMiddleware(); app.UseMiddleware(); // Must run before UseFastEndpoints — AuthSchemes()/Roles() on CandidatePortal endpoints need // HttpContext.User populated by the time the endpoint executes (mirrors EntitlementSL/Program.cs's // identical ordering fix for its own ClientAuth endpoints). Every existing AllowAnonymous() // endpoint in this module is unaffected — no default scheme was registered above. app.UseAuthentication(); app.UseAuthorization(); app.UseFastEndpoints(c => { c.Serializer.Options.PropertyNamingPolicy = null; c.Serializer.Options.DictionaryKeyPolicy = null; c.Serializer.Options.AddGB5Converters(); }); app.MapControllers(); app.MapSubscribeHandler(); // ------------------------- // Swagger UI (All Environments) // ------------------------- app.UseOpenApi(); app.UseSwaggerUi(o => { o.Path = "/GB5Documentation"; o.DocumentPath = "/swagger/{documentName}/swagger.json"; o.TransformToExternalPath = (internalUiRoute, _) => "/rec" + internalUiRoute; }); // ------------------------- // Test Endpoint // ------------------------- app.MapGet("/", () => "Hello from GB5 Recruitment Service (.NET 9)"); // ------------------------- // SignalR Hub // ------------------------- app.MapHub("/hubs/recruitment"); // ------------------------- // Run the Application // ------------------------- app.Run();