using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net.Mail; using System.Threading; using System.Threading.Tasks; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using RecruitmentBLL.Application; using RecruitmentBLL.Candidate; using RecruitmentBLL.SelectionProcess; using RecruitmentDAL.CustomeCode.Career; using RecruitmentDAL.DTO.Application; using RecruitmentDAL.DTO.Candidate; using RecruitmentDAL.DTO.Career; using RecruitmentDAL.DTO.SelectionProcess; namespace RecruitmentBLL.Career { // Public career-site surface (Phase 3). Deliberately does NOT own an entity save pipeline // of its own — GetPublishedJobs/GetPublishedJobDetail read through ICareerDAL's dedicated, // pre-filtered (Published+Active+tenant) queries, and SubmitApplication composes the // *existing*, already-instrumented ICandidateBLL.SaveCandidate / IApplicationBLL.SaveApplication // pipelines (ExecuteSaveAsync, AutoNumber, event-publish, cache invalidation all already // happen inside those calls) rather than duplicating that machinery here. public class CareerBLL : ICareerBLL { private readonly ICareerDAL _CareerDAL; private readonly ICandidateBLL _CandidateBLL; private readonly IApplicationBLL _ApplicationBLL; private readonly ISelectionProcessBLL _SelectionProcessBLL; private readonly ILogger _Logger; public CareerBLL( ICareerDAL careerDAL, ICandidateBLL candidateBLL, IApplicationBLL applicationBLL, ISelectionProcessBLL selectionProcessBLL, ILogger logger) { _CareerDAL = careerDAL; _CandidateBLL = candidateBLL; _ApplicationBLL = applicationBLL; _SelectionProcessBLL = selectionProcessBLL; _Logger = logger; } #region Get public async Task GetPublishedJobs(int FirstNumber, int MaxResult, LoginDTO LoginDTO, CancellationToken ct) { try { // Public listing default/cap — mirrors CandidateBLL.GetSelectListCandidate's // "don't fall back to unbounded" rule; an anonymous caller must never be able to // request an unbounded page. if (FirstNumber <= 0) FirstNumber = 1; if (MaxResult <= 0 || MaxResult > 100) MaxResult = 20; return await _CareerDAL.GetPublishedJobs(FirstNumber, MaxResult, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-publishedjobs-failed", ex); _Logger.LogError(ex, "GetPublishedJobs failed"); throw; } } public async Task GetPublishedJobDetail(int JobRequisitionId, LoginDTO LoginDTO, CancellationToken ct) { try { var json = await _CareerDAL.GetPublishedJobDetail(JobRequisitionId, LoginDTO, ct) .ConfigureAwait(false); var detail = JsonConvert.DeserializeObject(json ?? "null"); if (detail == null) throw new ValidationException("This job posting was not found or is no longer accepting applications."); return json; } catch (ValidationException vex) { GB5Trace.MarkFailed("get-publishedjobdetail-failed", vex); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("get-publishedjobdetail-failed", ex); _Logger.LogError(ex, "GetPublishedJobDetail failed for JobRequisitionId {JobRequisitionId}", JobRequisitionId); throw; } } #endregion #region Submit Application public async Task SubmitApplication(CareerApplicationSubmissionDTO Submission, LoginDTO LoginDTO, CancellationToken ct) { if (Submission == null) throw new ArgumentNullException(nameof(Submission)); try { GB5Trace.Step("validate-career-application", new { Submission.JobRequisitionId, Submission.Email }); if (string.IsNullOrWhiteSpace(Submission.FirstName)) throw new ValidationException($"{nameof(Submission.FirstName)} is required."); if (string.IsNullOrWhiteSpace(Submission.Email)) throw new ValidationException($"{nameof(Submission.Email)} is required."); // Same minimal, self-contained email-format check as CandidateBLL.SaveCandidate — // IValidation exposes no email-format check (shared, framework-wide surface). try { _ = new MailAddress(Submission.Email); } catch (FormatException) { throw new ValidationException($"{nameof(Submission.Email)} is not a valid email address."); } if (Submission.JobRequisitionId <= 0) throw new ValidationException($"{nameof(Submission.JobRequisitionId)} is required."); // GDPR — must be explicit; never silently defaulted true. if (!Submission.ConsentGiven) throw new ValidationException("Consent must be given before an application can be submitted."); // ── Confirm the target requisition is a genuine, currently Published+Active // apply target for this tenant — reject Draft/PendingApproval/Approved/ // Rejected/OnHold/Closed requisitions and unknown ids identically. ────────── var requisition = await _CareerDAL .GetPublishedRequisitionForApply(Submission.JobRequisitionId, LoginDTO, ct) .ConfigureAwait(false); if (requisition == null) throw new ValidationException("This job posting is not currently accepting applications."); // ── Reuse an existing Candidate by email, or create a new one ───────────────── GB5Trace.Step("resolve-career-candidate", new { Submission.Email }); var existingJson = await _CandidateBLL .GetCandidateByEmail(Submission.Email, LoginDTO, ct) .ConfigureAwait(false); var existing = JsonConvert.DeserializeObject(existingJson ?? "null"); int candidateId; if (existing != null && existing.CandidateId > 0) { // Phase 6 (internal mobility) guard: an existing employee's Candidate record // (CandidateType=2, InternalEmployee) must never pick up a second Application // through the public, unauthenticated career-site route — internal mobility // has its own endpoint (POST /InternalMobility/SubmitInternalApplication / // RecruitmentBLL.InternalMobility.InternalMobilityBLL) specifically so an // internal applicant's identity is resolved from MEMPLOYEE, not re-entered // anonymously here. See CandidateDTO.CandidateType's doc comment. if (existing.CandidateType == 2) throw new ValidationException( "This email belongs to a current employee. Please use the internal careers " + "application process to apply for internal openings."); candidateId = existing.CandidateId; _Logger.LogInformation( "Career apply: reusing existing Candidate {CandidateId} for JobRequisition {JobRequisitionId}", candidateId, Submission.JobRequisitionId); } else { var candidateDto = new CandidateDTO { FirstName = Submission.FirstName, LastName = Submission.LastName, Email = Submission.Email, Phone = Submission.Phone, ResumeUrl = Submission.ResumeUrl, Source = 1, // Website ConsentGiven = Submission.ConsentGiven, ConsentDate = DateTime.UtcNow }; GB5Trace.Step("save-career-candidate", new { Submission.Email }); // Routes through CandidateBLL.SaveCandidate — ExecuteSaveAsync, AutoNumber, // event-publish and cache invalidation all already happen inside that call. await _CandidateBLL.SaveCandidate(candidateDto, LoginDTO, ct).ConfigureAwait(false); candidateId = candidateDto.CandidateId; _Logger.LogInformation( "Career apply: created new Candidate {CandidateId} for JobRequisition {JobRequisitionId}", candidateId, Submission.JobRequisitionId); } // ── Resolve the pipeline's first stage (StageOrder = 1) from the requisition's // SelectionProcessTemplate — reuses ISelectionProcessBLL.GetSelectionProcessStage // (already returns every stage ordered by StageOrder) rather than hand-rolling // a duplicate query. ───────────────────────────────────────────────────────── int firstStageId = -1; var stagesJson = await _SelectionProcessBLL .GetSelectionProcessStage(requisition.SelectionProcessTemplateId, LoginDTO, ct) .ConfigureAwait(false); var stages = JsonConvert.DeserializeObject>(stagesJson ?? "[]"); var firstStage = stages?.OrderBy(s => s.StageOrder).FirstOrDefault(); if (firstStage != null) firstStageId = firstStage.SelectionProcessStageId; // ── Create the Application, linking Candidate x JobRequisition ──────────────── var applicationDto = new ApplicationDTO { CandidateId = candidateId, JobRequisitionId = Submission.JobRequisitionId, CurrentStageId = firstStageId, ApplicationSource = 1, // Website AppliedOn = DateTime.UtcNow }; GB5Trace.Step("save-career-application", new { candidateId, Submission.JobRequisitionId }); // Routes through ApplicationBLL.SaveApplication — ExecuteSaveAsync, AutoNumber, // event-publish and cache invalidation all already happen inside that call. var applicationResult = await _ApplicationBLL .SaveApplication(applicationDto, LoginDTO, ct) .ConfigureAwait(false); GB5Trace.Step("event-publish", new { CandidateId = candidateId, Submission.JobRequisitionId }); _Logger.LogInformation( "Career apply: Application submitted for Candidate {CandidateId} against JobRequisition {JobRequisitionId}", candidateId, Submission.JobRequisitionId); return applicationResult; } catch (ValidationException vex) { GB5Trace.MarkFailed("submit-career-application-failed", vex); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("submit-career-application-failed", ex); _Logger.LogError(ex, "SubmitApplication failed for Email {Email} JobRequisitionId {JobRequisitionId}", Submission?.Email, Submission?.JobRequisitionId); throw; } } #endregion } }