using System; using System.IO; using System.Net.Mail; using System.Threading; using System.Threading.Tasks; using GB5Shared.Attachment; using GB5Shared.Auth.Jwt; using GB5Shared.DTO.ECM; using GB5Shared.DTO.Framework.Login; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using RecruitmentBLL.Application; using RecruitmentBLL.Candidate; using RecruitmentBLL.Integration; using RecruitmentDAL.CustomeCode.FlsBridge; using RecruitmentDAL.DTO.Candidate; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.CandidatePortal { /// /// Orchestrates the candidate self-service portal (Phase 3): DXP-backed register/login, /// email-based Candidate linking, application-history read, and document upload. /// /// JWT design decision (the one genuinely non-obvious call in this class): after a /// successful DXP login, Recruitment does NOT relay DXP's own Login-issued token back to the /// client. DXP's AuthBLL.LoginAsync only issues a full access+refresh DXPTokenPair when the /// caller has exactly one (Party/Role, tenant-link) context — and a Recruitment candidate has /// NO DXP Party/Role at all (the plan's Phase 3 note: DXP's PartyType enum is a closed /// vendor/customer taxonomy that doesn't fit an individual candidate). So Contexts is always /// empty and DXP's Login falls into its "else" branch, issuing only a bare /// "IntermediateToken" whose one real claim is dxp_user_id — RoleCode/TenantId/DatabaseName/ /// LocalPartyId are all zero/empty (see DXPBLL.Auth.AuthBLL.LoginAsync). That token carries /// nothing Recruitment's endpoints need (a CandidateId) and normally exists only so a caller /// can immediately call ContextSwitch — which a candidate, having no contexts, never can. /// /// Instead, once DXP confirms the credentials are correct, Recruitment resolves/creates the /// matching MCANDIDATE row by email (EnsureCandidateAsync — the "first-login linking step") /// and mints its OWN access token via the shared GB5Shared.Auth.Jwt.IJwtAccessTokenIssuer, /// signed with DXP's own signing key/issuer/audience (Vault path "dxp/jwt-signing-key", /// config "DXPJwt:Issuer"/"DXPJwt:Audience" — the exact same three values DXPSL/Program.cs's /// own AddJwtBearer uses), carrying CandidateId directly as a claim. RecruitmentSL's /// "CandidatePortal" JwtBearer scheme (see Program.cs) validates it exactly the same way DXP /// validates its own tokens — signature/issuer/audience/lifetime — so from the validation /// pipeline's point of view it is indistinguishable from a DXP-minted token. Two modules /// intentionally sharing one signing key for a specific, narrow purpose is not unprecedented /// here: EntitlementSL's "PartnerM2MJwtBearer" scheme documents the identical exception /// (Partner issues, Entitlement validates, same key) — this is that same pattern, just with /// the issuer/validator roles reversed (Recruitment issues using DXP's key/identity claims, /// both DXP and Recruitment can validate). /// public class CandidatePortalBLL : ICandidatePortalBLL { // Must equal DXPBLL.Auth.DXPJwtService's private SigningKeyVaultPath constant — a token // signed at any other path would never validate under RecruitmentSL's "CandidatePortal" // scheme, which is hardcoded to this exact path (see RecruitmentSL/Program.cs). private const string SigningKeyVaultPath = "dxp/jwt-signing-key"; // Candidate self-registered through the portal, same taxonomy CandidateDTO.Source already // documents: "1=Website 2=JobPortal 3=Agency 4=Referral 5=Internal 6=Direct/Manual". private const byte SourceWebsite = 1; private readonly IDxpAuthIntegrationService _DxpAuthIntegrationService; private readonly ICandidateBLL _CandidateBLL; private readonly IApplicationBLL _ApplicationBLL; private readonly IAttachmentUploadService _AttachmentUploadService; private readonly IRecruitmentFlsActionDAL _FlsActionDAL; private readonly IJwtAccessTokenIssuer _JwtIssuer; private readonly IConfiguration _Configuration; private readonly ILogger _Logger; public CandidatePortalBLL( IDxpAuthIntegrationService dxpAuthIntegrationService, ICandidateBLL candidateBLL, IApplicationBLL applicationBLL, IAttachmentUploadService attachmentUploadService, IRecruitmentFlsActionDAL flsActionDAL, IJwtAccessTokenIssuer jwtIssuer, IConfiguration configuration, ILogger logger) { _DxpAuthIntegrationService = dxpAuthIntegrationService; _CandidateBLL = candidateBLL; _ApplicationBLL = applicationBLL; _AttachmentUploadService = attachmentUploadService; _FlsActionDAL = flsActionDAL; _JwtIssuer = jwtIssuer; _Configuration = configuration; _Logger = logger; } public async Task RegisterAsync(string fullName, string email, string? mobile, string password, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-candidate-portal-register", new { email }); if (string.IsNullOrWhiteSpace(fullName)) throw new ArgumentException("FullName is required."); if (string.IsNullOrWhiteSpace(email)) throw new ArgumentException("Email is required."); try { _ = new MailAddress(email); } catch (FormatException) { throw new ArgumentException("Email is not a valid email address."); } GB5Trace.Step("dxp-register-passthrough", new { email }); var outcome = await _DxpAuthIntegrationService .RegisterCandidateUserAsync(fullName, email, mobile, password, ct) .ConfigureAwait(false); if (!outcome.Success) { GB5Trace.MarkFailed("candidate-portal-register-failed", new InvalidOperationException(outcome.Error)); _Logger.LogWarning("Candidate portal registration failed for {Email}: {Error}", email, outcome.Error); throw new InvalidOperationException(outcome.Error ?? "Registration failed."); } // Eager first-login link — a candidate who never applied through the public career // site yet still gets an MCANDIDATE row the moment they register for the portal. await EnsureCandidateAsync(fullName, email, login, ct).ConfigureAwait(false); return SuccessResponse.SaveSuccess; } public async Task LoginAsync(string email, string password, LoginDTO login, CancellationToken ct) { GB5Trace.Step("dxp-login-passthrough", new { email }); var outcome = await _DxpAuthIntegrationService.LoginCandidateAsync(email, password, ct).ConfigureAwait(false); if (!outcome.Success) { GB5Trace.MarkFailed("candidate-portal-login-failed", new InvalidOperationException(outcome.Error)); return new CandidatePortalLoginResultDTO { Success = false, Error = outcome.Error ?? "Invalid email or password." }; } GB5Trace.Step("resolve-candidate-portal-candidate", new { email }); var candidateId = await EnsureCandidateAsync(outcome.FullName, email, login, ct).ConfigureAwait(false); var accessTokenMinutes = _Configuration.GetValue("DXPJwt:AccessTokenMinutes", 15); var issuer = _Configuration["DXPJwt:Issuer"] ?? "GB5-DXP"; var audience = _Configuration["DXPJwt:Audience"] ?? "GB5-DXP-Portal"; var claims = new CandidatePortalAccessTokenClaims { DxpUserId = outcome.DxpUserId, CandidateId = candidateId, Email = email }; GB5Trace.Step("issue-candidate-portal-token", new { candidateId, outcome.DxpUserId }); var issued = await _JwtIssuer .IssueAsync(claims, SigningKeyVaultPath, issuer, audience, accessTokenMinutes, ct) .ConfigureAwait(false); return new CandidatePortalLoginResultDTO { Success = true, CandidateId = candidateId, Email = email, AccessToken = issued.AccessToken, AccessTokenExpiresOn = issued.ExpiresOn }; } public async Task GetMyApplicationsAsync(int candidateId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("candidate-portal-my-applications", new { candidateId }); return await _ApplicationBLL.GetApplicationsByCandidate(candidateId, login, ct).ConfigureAwait(false); } public async Task UploadDocumentAsync( int candidateId, Stream fileStream, string fileName, string contentType, string? remarks, LoginDTO login, CancellationToken ct) { GB5Trace.Step("candidate-portal-upload-document", new { candidateId, fileName }); if (candidateId <= 0) throw new ArgumentException("CandidateId is required.", nameof(candidateId)); // Read-only reuse of the FLS bridge's existing generic ModuleId-by-code resolver // (RecruitmentFlsBridgeBLL already calls this exact method the same way) — not a // modification of RecruitmentFlsActionDAL/FlsBridge, which is out of scope here. var moduleId = await _FlsActionDAL.ResolveModuleIdByCode("RECRUITMENT", login, ct).ConfigureAwait(false); var documentSetDetailId = await _AttachmentUploadService.EnsureDocumentSetDetailAsync( documentSetCode: "RECRUITMENTCANDIDATEDOC", documentSetName: "Recruitment Candidate Documents", documentTypeCode: "RECRUITMENTCANDIDATEDOC", documentTypeName: "Candidate Self-Service Document", moduleId: moduleId, login: login, ct: ct).ConfigureAwait(false); var uploadRequest = new AttachmentUploadRequest { ObjectTypeId = EntityConstant.OBJECTRECRUITMENTCANDIDATEDOC, ObjectId = candidateId, DocumentSetDetailId = documentSetDetailId, RowGuid = Guid.NewGuid(), Remarks = remarks, Tags = "CandidatePortal" }; var result = await _AttachmentUploadService .UploadAsync(uploadRequest, fileStream, fileName, contentType, login, ct) .ConfigureAwait(false); GB5Trace.Step("candidate-portal-upload-document-done", new { candidateId, result.AttachmentId }); return result; } /// Same dedup-by-email pattern as RecruitmentBLL.Career.CareerBLL.SubmitApplication /// — reuses the *existing*, already-instrumented ICandidateBLL.GetCandidateByEmail/ /// SaveCandidate pipeline (ExecuteSaveAsync, AutoNumber, event-publish, cache invalidation /// all already happen inside those calls) rather than duplicating it. private async Task EnsureCandidateAsync(string fullName, string email, LoginDTO login, CancellationToken ct) { var existingJson = await _CandidateBLL.GetCandidateByEmail(email, login, ct).ConfigureAwait(false); var existing = JsonConvert.DeserializeObject(existingJson ?? "null"); if (existing != null && existing.CandidateId > 0) return existing.CandidateId; var (firstName, lastName) = SplitFullName(fullName); var candidateDto = new CandidateDTO { FirstName = firstName, LastName = lastName, Email = email, Source = SourceWebsite, ConsentGiven = false }; await _CandidateBLL.SaveCandidate(candidateDto, login, ct).ConfigureAwait(false); return candidateDto.CandidateId; } private static (string FirstName, string LastName) SplitFullName(string fullName) { if (string.IsNullOrWhiteSpace(fullName)) return ("Candidate", string.Empty); var parts = fullName.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); return parts.Length == 2 ? (parts[0], parts[1]) : (parts[0], string.Empty); } } }