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);
}
}
}