using System; using System.ComponentModel.DataAnnotations; using System.Net.Mail; using System.Threading; using System.Threading.Tasks; using RecruitmentDAL.DTO.Candidate; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Microsoft.Extensions.Logging; using RecruitmentDAL.CustomeCode.Candidate; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.Candidate { public class CandidateBLL : ICandidateBLL { private readonly ICandidateDAL _CandidateDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly KeyInvalidate _KeyInvalidate; private readonly BaseEntityAppService _BaseEntityAppService; private readonly ILogger _Logger; public CandidateBLL( ICandidateDAL candidateDAL, AutoNumber autoNumber, IValidation validation, KeyInvalidate keyInvalidate, BaseEntityAppService baseEntityAppService, ILogger logger) { _CandidateDAL = candidateDAL; _AutoNumber = autoNumber; _Validation = validation; _KeyInvalidate = keyInvalidate; _BaseEntityAppService = baseEntityAppService; _Logger = logger; } public async Task GetCandidate(int CandidateId, LoginDTO LoginDTO, CancellationToken ct) { try { return await _CandidateDAL.GetCandidate(CandidateId, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-candidate-failed", ex); _Logger.LogError(ex, "GetCandidate failed for CandidateId {CandidateId}", CandidateId); throw; } } public async Task SaveCandidate(CandidateDTO CandidateDTO, LoginDTO LoginDTO, CancellationToken ct) { if (CandidateDTO == null) throw new ArgumentNullException(nameof(CandidateDTO)); AutoNumberDTO? autoNumberDTO = null; try { GB5Trace.Step("validate-candidate", new { CandidateDTO.CandidateId }); await _Validation.NotEmpty( CandidateDTO.FirstName, nameof(CandidateDTO.FirstName)); await _Validation.NotEmpty( CandidateDTO.Email, nameof(CandidateDTO.Email)); // IValidation only exposes NotNull/NotEmpty/NotNullOrEmpty/MaxLength/Range/ // HandleException — no email-format check — and it is shared, framework-wide // surface used by every module, so it is not widened for this one entity. // System.Net.Mail.MailAddress's constructor is the minimal, self-contained // format check: it throws FormatException on a malformed address, which is // converted here into the same ValidationException type NotEmpty/NotNullOrEmpty // already throw (and that this method's catch block already handles). try { _ = new MailAddress(CandidateDTO.Email); } catch (FormatException) { throw new ValidationException($"{nameof(CandidateDTO.Email)} is not a valid email address."); } // ── Internal mobility / contingent workforce (Phase 6) — CandidateType-scoped // cross-field rules. CandidateType itself has a DB-level CHECK (1/2/3); these // are business rules the schema can't express (see the Phase 6 migration's // ENGAGEMENTTYPE doc comment for why they live here, not in a CHECK/trigger). ── switch (CandidateDTO.CandidateType) { case 2: // InternalEmployee if (CandidateDTO.EmployeeId <= 0) throw new ValidationException( $"{nameof(CandidateDTO.EmployeeId)} is required for an InternalEmployee Candidate."); break; case 3: // ContingentGig if (CandidateDTO.EngagementType.HasValue && (CandidateDTO.EngagementType < 1 || CandidateDTO.EngagementType > 4)) throw new ValidationException( $"{nameof(CandidateDTO.EngagementType)} must be one of 1=Contract 2=Freelance 3=Temporary 4=Consultant."); break; default: // External (1) — EngagementType/ContractDurationMonths are ContingentGig-only. if (CandidateDTO.EngagementType.HasValue || CandidateDTO.ContractDurationMonths.HasValue) throw new ValidationException( $"{nameof(CandidateDTO.EngagementType)}/{nameof(CandidateDTO.ContractDurationMonths)} " + "only apply to a ContingentGig Candidate."); break; } CandidateDTO.ModifiedById = LoginDTO.UserId; CandidateDTO.ModifiedOn = DateTime.UtcNow; bool isNew = CandidateDTO.CandidateId == 0; if (isNew) { autoNumberDTO = await _AutoNumber.GetNumberAsync( 1, "CANDIDATE", LoginDTO).ConfigureAwait(false); CandidateDTO.CandidateId = autoNumberDTO.StartNumber; CandidateDTO.CreatedById = LoginDTO.UserId; CandidateDTO.CreatedOn = DateTime.UtcNow; CandidateDTO.TenantId = LoginDTO.ClientId; } GB5Trace.Step("save-candidate", new { CandidateDTO.CandidateId, isNew }); await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTRECRUITMENTCANDIDATE, isNew ? EventTypeConstant.SAVERECRUITMENTCANDIDATEEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTCANDIDATEEVENTTYPEID, CandidateDTO, LoginDTO, async tx => { _ = isNew ? await _CandidateDAL.SaveCandidate(CandidateDTO, LoginDTO, tx, ct).ConfigureAwait(false) : await _CandidateDAL.UpdateCandidate(CandidateDTO, LoginDTO, tx, ct).ConfigureAwait(false); return CandidateDTO.CandidateId; }, null, -1, -1, null, isNew).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = isNew ? EventTypeConstant.SAVERECRUITMENTCANDIDATEEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTCANDIDATEEVENTTYPEID }); var cacheKey = new CacheKeyGeneration().KeyGeneration( CandidateDTO.CandidateId, EntityConstant.OBJECTRECRUITMENTCANDIDATE, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation( "Candidate {CandidateId} {Action} by user {UserId}", CandidateDTO.CandidateId, isNew ? "saved" : "updated", LoginDTO.UserId); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {CandidateDTO.CandidateId}" : $"{SuccessResponse.UpdateSuccessMessage} {CandidateDTO.CandidateId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-candidate-failed", vex); if (autoNumberDTO != null && CandidateDTO.CandidateId > 0) await _AutoNumber.RollbackAutoNumber("CANDIDATE", CandidateDTO.CandidateId, LoginDTO).ConfigureAwait(false); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-candidate-failed", ex); _Logger.LogError(ex, "SaveCandidate failed for CandidateId {CandidateId}", CandidateDTO.CandidateId); if (autoNumberDTO != null && CandidateDTO.CandidateId > 0) { await _AutoNumber.RollbackAutoNumber( "CANDIDATE", CandidateDTO.CandidateId, LoginDTO).ConfigureAwait(false); } throw; } } public async Task GetSelectListCandidate(int FirstNumber, int MaxResult, CriteriaDTO criteriaDTO, LoginDTO LoginDTO, CancellationToken ct) { try { // Backward-compatible default: unlike GetSelectListAction's GB4-compatibility // shim (0,0 forwarded as -1,-1 meaning "return all"), an unset/invalid page here // must NOT fall back to unbounded — the candidate pool is exactly the list that // is expected to grow large and never gets manually pruned. Default to a sensible // 100-row page instead. if (FirstNumber <= 0) FirstNumber = 1; if (MaxResult <= 0) MaxResult = 100; return await _CandidateDAL.GetSelectListCandidate(FirstNumber, MaxResult, criteriaDTO, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-selectlist-candidate-failed", ex); _Logger.LogError(ex, "GetSelectListCandidate failed"); throw; } } public async Task DeleteCandidate(int CandidateId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("delete-candidate", new { CandidateId }); var result = await _CandidateDAL.DeleteCandidate(CandidateId, LoginDTO, ct) .ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( CandidateId, EntityConstant.OBJECTRECRUITMENTCANDIDATE, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation("Candidate {CandidateId} deleted by user {UserId}", CandidateId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-candidate-failed", ex); _Logger.LogError(ex, "DeleteCandidate failed for CandidateId {CandidateId}", CandidateId); throw; } } // Duplicate-applicant lookup for the public career-site apply flow // (RecruitmentBLL.Career.CareerBLL.SubmitApplication) — read-only, not an entity save. public async Task GetCandidateByEmail(string Email, LoginDTO LoginDTO, CancellationToken ct) { try { return await _CandidateDAL.GetCandidateByEmail(Email, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-candidate-by-email-failed", ex); _Logger.LogError(ex, "GetCandidateByEmail failed"); throw; } } // Bridge for gb4.7mfe's legacy Applicant dashboard (projects/recruitment/dashboard/ // applicantdashboard) — resolves the batch of legacy applicant emails the FE already has // (from GET /prs/Applicant.svc/ApplicantReport, an external legacy GB4 service this repo // does not own) against real MCANDIDATE/TAPPLICATION rows. Read-only lookup, not an entity // save — no ExecuteSaveAsync/AutoNumber/event-publish involved. public async Task GetCurrentStageByEmail(IEnumerable Emails, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("get-candidate-current-stage-by-email", new { EmailCount = Emails?.Count() ?? 0 }); return await _CandidateDAL.GetCurrentStageByEmail(Emails, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-candidate-current-stage-by-email-failed", ex); _Logger.LogError(ex, "GetCurrentStageByEmail failed"); throw; } } } }