using System; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; using RecruitmentDAL.DTO.Application; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.EventLogPublish; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using RecruitmentBLL.Common; using RecruitmentBLL.Integration; using RecruitmentBLL.Referral; using RecruitmentBLL.SelectionProcess; using RecruitmentDAL.CustomeCode.Application; using TMSBLL.Quiz; using TMSDAL.DTO.Quiz; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.Application { public class ApplicationBLL : IApplicationBLL { private readonly IApplicationDAL _ApplicationDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly KeyInvalidate _KeyInvalidate; private readonly EventLogPublish _EventLog; private readonly BaseEntityAppService _BaseEntityAppService; private readonly BaseEntityAppService _BaseEntityAppServiceStageHistory; private readonly IRecruitmentHubNotifier _HubNotifier; private readonly IEcpIntegrationService _EcpIntegrationService; private readonly ISelectionProcessBLL _SelectionProcessBLL; private readonly IQuizBLL _QuizBLL; private readonly IReferralBLL _ReferralBLL; private readonly ILogger _Logger; // TAPPLICATIONSTAGEHISTORY.OUTCOME / DROPOFFREASON — see Phase 3 migration comment. private const byte OUTCOME_DROPPEDOFF = 6; private const byte DROPOFFREASON_NOTAPPLICABLE = 0; // Same OUTCOME=2 ("Passed") value as CandidateConversionBLL's own OUTCOME_PASSED constant // — named separately here since it gates the Referral bonus-eligibility hook specifically, // not the Candidate->Employee conversion check. private const byte OUTCOME_PASSED_FOR_REFERRAL = 2; public ApplicationBLL( IApplicationDAL applicationDAL, AutoNumber autoNumber, IValidation validation, KeyInvalidate keyInvalidate, EventLogPublish eventLog, BaseEntityAppService baseEntityAppService, BaseEntityAppService baseEntityAppServiceStageHistory, IRecruitmentHubNotifier hubNotifier, IEcpIntegrationService ecpIntegrationService, ISelectionProcessBLL selectionProcessBLL, IQuizBLL quizBLL, IReferralBLL referralBLL, ILogger logger) { _ApplicationDAL = applicationDAL; _AutoNumber = autoNumber; _Validation = validation; _KeyInvalidate = keyInvalidate; _EventLog = eventLog; _BaseEntityAppService = baseEntityAppService; _BaseEntityAppServiceStageHistory = baseEntityAppServiceStageHistory; _HubNotifier = hubNotifier; _EcpIntegrationService = ecpIntegrationService; _SelectionProcessBLL = selectionProcessBLL; _QuizBLL = quizBLL; _ReferralBLL = referralBLL; _Logger = logger; } #region Get public async Task GetApplication(int ApplicationId, LoginDTO LoginDTO, CancellationToken ct) { try { return await _ApplicationDAL.GetApplication(ApplicationId, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-application-failed", ex); _Logger.LogError(ex, "GetApplication failed for ApplicationId {ApplicationId}", ApplicationId); throw; } } #endregion #region Save public async Task SaveApplication(ApplicationDTO ApplicationDTO, LoginDTO LoginDTO, CancellationToken ct) { if (ApplicationDTO == null) throw new ArgumentNullException(nameof(ApplicationDTO)); AutoNumberDTO? autoNumberDTO = null; try { GB5Trace.Step("validate-application", new { ApplicationDTO.ApplicationId }); bool isNew = ApplicationDTO.ApplicationId == 0; ApplicationDTO.TenantId = LoginDTO.ClientId; ApplicationDTO.ModifiedById = LoginDTO.UserId; ApplicationDTO.ModifiedOn = DateTime.UtcNow; if (isNew) { autoNumberDTO = await _AutoNumber.GetNumberAsync( 1, "APPLICATION", LoginDTO).ConfigureAwait(false); ApplicationDTO.ApplicationId = autoNumberDTO.StartNumber; ApplicationDTO.CreatedById = LoginDTO.UserId; ApplicationDTO.CreatedOn = DateTime.UtcNow; } GB5Trace.Step("save-application", new { ApplicationDTO.ApplicationId, isNew }); await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTRECRUITMENTAPPLICATION, isNew ? EventTypeConstant.SAVERECRUITMENTAPPLICATIONEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTAPPLICATIONEVENTTYPEID, ApplicationDTO, LoginDTO, async tx => { _ = isNew ? await _ApplicationDAL.SaveApplication(ApplicationDTO, LoginDTO, tx, ct).ConfigureAwait(false) : await _ApplicationDAL.UpdateApplication(ApplicationDTO, LoginDTO, tx, ct).ConfigureAwait(false); return ApplicationDTO.ApplicationId; }, null, -1, -1, null, isNew).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = isNew ? EventTypeConstant.SAVERECRUITMENTAPPLICATIONEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTAPPLICATIONEVENTTYPEID }); var cacheKey = new CacheKeyGeneration().KeyGeneration( ApplicationDTO.ApplicationId, EntityConstant.OBJECTRECRUITMENTAPPLICATION, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation( "Application {ApplicationId} {Action} by user {UserId}", ApplicationDTO.ApplicationId, isNew ? "saved" : "updated", LoginDTO.UserId); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {ApplicationDTO.ApplicationId}" : $"{SuccessResponse.UpdateSuccessMessage} {ApplicationDTO.ApplicationId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-application-failed", vex); if (autoNumberDTO != null && ApplicationDTO.ApplicationId > 0) await _AutoNumber.RollbackAutoNumber("APPLICATION", ApplicationDTO.ApplicationId, LoginDTO).ConfigureAwait(false); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-application-failed", ex); _Logger.LogError(ex, "SaveApplication failed for ApplicationId {ApplicationId}", ApplicationDTO.ApplicationId); if (autoNumberDTO != null && ApplicationDTO.ApplicationId > 0) { await _AutoNumber.RollbackAutoNumber( "APPLICATION", ApplicationDTO.ApplicationId, LoginDTO).ConfigureAwait(false); } throw; } } #endregion #region Select List public async Task GetSelectListApplication(CriteriaDTO criteriaDTO, LoginDTO LoginDTO, CancellationToken ct) { try { return await _ApplicationDAL.GetSelectListApplication(criteriaDTO, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-selectlist-application-failed", ex); _Logger.LogError(ex, "GetSelectListApplication failed"); throw; } } // Candidate-scoped application history — thin passthrough for the candidate self-service // portal (Phase 3). See ApplicationDAL.GetApplicationsByCandidate. public async Task GetApplicationsByCandidate(int CandidateId, LoginDTO LoginDTO, CancellationToken ct) { try { return await _ApplicationDAL.GetApplicationsByCandidate(CandidateId, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-applications-by-candidate-failed", ex); _Logger.LogError(ex, "GetApplicationsByCandidate failed for CandidateId {CandidateId}", CandidateId); throw; } } #endregion #region Delete public async Task DeleteApplication(int ApplicationId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("delete-application", new { ApplicationId }); var result = await _ApplicationDAL.DeleteApplication(ApplicationId, LoginDTO, ct) .ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.DELETERECRUITMENTAPPLICATIONEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Application Deleted", new { ApplicationId }, EventTypeConstant.DELETERECRUITMENTAPPLICATIONEVENTTYPEID, ApplicationId, LoginDTO, ct: ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( ApplicationId, EntityConstant.OBJECTRECRUITMENTAPPLICATION, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation("Application {ApplicationId} deleted by user {UserId}", ApplicationId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-application-failed", ex); _Logger.LogError(ex, "DeleteApplication failed for ApplicationId {ApplicationId}", ApplicationId); throw; } } #endregion #region Get public async Task GetApplicationStageHistory(int ApplicationStageHistoryId, LoginDTO LoginDTO, CancellationToken ct) { try { return await _ApplicationDAL.GetApplicationStageHistory(ApplicationStageHistoryId, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-applicationstagehistory-failed", ex); _Logger.LogError(ex, "GetApplicationStageHistory failed for ApplicationStageHistoryId {ApplicationStageHistoryId}", ApplicationStageHistoryId); throw; } } #endregion #region Save // Saving a new stage-history row IS the "advance the pipeline" operation: once the // history row is safely committed via ExecuteSaveAsync, this also updates the parent // TAPPLICATION.CURRENTSTAGEID to the stage the candidate just moved into. That follow-up // call is a small, sequential, non-transactional UPDATE (see IApplicationDAL // .UpdateApplicationCurrentStage) — not part of the stage-history's own save transaction. // Worst case on failure there is a stage-history row whose parent pointer hasn't caught // up yet: recoverable, not data corruption. public async Task SaveApplicationStageHistory(ApplicationStageHistoryDTO ApplicationStageHistoryDTO, LoginDTO LoginDTO, CancellationToken ct) { if (ApplicationStageHistoryDTO == null) throw new ArgumentNullException(nameof(ApplicationStageHistoryDTO)); AutoNumberDTO? autoNumberDTO = null; try { GB5Trace.Step("validate-applicationstagehistory", new { ApplicationStageHistoryDTO.ApplicationStageHistoryId }); // OUTCOME 6 = DroppedOff: a drop-off without a real reason code (1-6) defeats the // purpose of tracking it, so it's rejected here. For any other Outcome, DropOffReason // just passes through as whatever the DTO carries (expected 0=N/A, but a caller // sending a stray value on a non-dropoff row isn't this method's concern to police). if (ApplicationStageHistoryDTO.Outcome == OUTCOME_DROPPEDOFF && ApplicationStageHistoryDTO.DropOffReason == DROPOFFREASON_NOTAPPLICABLE) { throw new ValidationException( $"DropOffReason must be set to a real reason (1-6) when Outcome is 6 (DroppedOff). Received: {ApplicationStageHistoryDTO.DropOffReason}."); } bool isNew = ApplicationStageHistoryDTO.ApplicationStageHistoryId == 0; ApplicationStageHistoryDTO.TenantId = LoginDTO.ClientId; ApplicationStageHistoryDTO.ModifiedById = LoginDTO.UserId; ApplicationStageHistoryDTO.ModifiedOn = DateTime.UtcNow; if (isNew) { autoNumberDTO = await _AutoNumber.GetNumberAsync( 1, "APPLICATIONSTAGEHISTORY", LoginDTO).ConfigureAwait(false); ApplicationStageHistoryDTO.ApplicationStageHistoryId = autoNumberDTO.StartNumber; ApplicationStageHistoryDTO.CreatedById = LoginDTO.UserId; ApplicationStageHistoryDTO.CreatedOn = DateTime.UtcNow; } GB5Trace.Step("save-applicationstagehistory", new { ApplicationStageHistoryDTO.ApplicationStageHistoryId, isNew }); await _BaseEntityAppServiceStageHistory.ExecuteSaveAsync( EntityConstant.OBJECTRECRUITMENTAPPLICATIONSTAGEHISTORY, isNew ? EventTypeConstant.SAVERECRUITMENTAPPLICATIONSTAGEHISTORYEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTAPPLICATIONSTAGEHISTORYEVENTTYPEID, ApplicationStageHistoryDTO, LoginDTO, async tx => { _ = isNew ? await _ApplicationDAL.SaveApplicationStageHistory(ApplicationStageHistoryDTO, LoginDTO, tx, ct).ConfigureAwait(false) : await _ApplicationDAL.UpdateApplicationStageHistory(ApplicationStageHistoryDTO, LoginDTO, tx, ct).ConfigureAwait(false); return ApplicationStageHistoryDTO.ApplicationStageHistoryId; }, null, -1, -1, null, isNew).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = isNew ? EventTypeConstant.SAVERECRUITMENTAPPLICATIONSTAGEHISTORYEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTAPPLICATIONSTAGEHISTORYEVENTTYPEID }); // ── Advance the pipeline: sync the parent's current stage ──────────────── // Read the pre-update Application row first, purely to capture the "old" stage // (and JobRequisitionId) for the live-push payload below — GetApplication is an // existing, read-only IApplicationDAL method, no DAL change required for this. int oldStageId = -1; int jobRequisitionId = -1; // Hoisted out of the try block below (originally a local var) so the // referral-eligibility-check hook further down can also read CandidateId off it // without a second GetApplication round-trip — no behavior change, same read. ApplicationDTO? currentApplication = null; try { var currentApplicationJson = await _ApplicationDAL .GetApplication(ApplicationStageHistoryDTO.ApplicationId, LoginDTO, ct) .ConfigureAwait(false); currentApplication = JsonConvert.DeserializeObject(currentApplicationJson); if (currentApplication != null) { oldStageId = currentApplication.CurrentStageId; jobRequisitionId = currentApplication.JobRequisitionId; } } catch (Exception ex) { // Non-fatal: worst case the live push carries OldSelectionProcessStageId = -1. _Logger.LogWarning(ex, "Could not read pre-update Application {ApplicationId} for stage-change push payload", ApplicationStageHistoryDTO.ApplicationId); } GB5Trace.Step("advance-application-currentstage", new { ApplicationStageHistoryDTO.ApplicationId, NewStageId = ApplicationStageHistoryDTO.SelectionProcessStageId }); await _ApplicationDAL.UpdateApplicationCurrentStage( ApplicationStageHistoryDTO.ApplicationId, ApplicationStageHistoryDTO.SelectionProcessStageId, LoginDTO, ct).ConfigureAwait(false); // ── Quiz-gated stage check (read-only) ──────────────────────────────── // If the stage the candidate just moved into names a required Quiz // (MSELECTIONPROCESSSTAGE.QuizId), log it so it's visible in the trace/audit // trail. This deliberately stops at a read-only check — it does NOT call // IQuizAttemptBLL.StartQuizAttempt. TQUIZATTEMPT.EMPLOYEEID is a NOT NULL // column with a real FK to MEMPLOYEE, and a Candidate (this DTO's // ApplicationId resolves to an MCANDIDATE row, never an MEMPLOYEE row until an // Offer is accepted and onboarding creates one) cannot be inserted there — // attempting it would fail with a foreign-key violation at the database, not // merely behave oddly. See IApplicationBLL.GetRequiredQuizForStage's doc // comment and Docs/Quiz-Assessment-Feature-Guide.md for the full explanation. // A non-fatal try/catch, same pattern as the hub push below: a quiz-lookup // failure must never fail the underlying stage-advance save. try { int requiredQuizId = await _SelectionProcessBLL .GetSelectionProcessStageQuizId(ApplicationStageHistoryDTO.SelectionProcessStageId, LoginDTO, ct) .ConfigureAwait(false); if (requiredQuizId > 0) { GB5Trace.Step("applicationstagehistory-quiz-required", new { ApplicationStageHistoryDTO.ApplicationId, ApplicationStageHistoryDTO.SelectionProcessStageId, QuizId = requiredQuizId }); _Logger.LogInformation( "Application {ApplicationId} entered stage {StageId} which requires Quiz {QuizId}; " + "no TQUIZATTEMPT created — Candidate has no MEMPLOYEE row (see GetRequiredQuizForStage doc comment)", ApplicationStageHistoryDTO.ApplicationId, ApplicationStageHistoryDTO.SelectionProcessStageId, requiredQuizId); } } catch (Exception ex) { GB5Trace.MarkFailed("applicationstagehistory-quiz-check-failed", ex); _Logger.LogWarning(ex, "Failed to check required Quiz for stage {StageId} on Application {ApplicationId}", ApplicationStageHistoryDTO.SelectionProcessStageId, ApplicationStageHistoryDTO.ApplicationId); } // ── Recruitment Phase 6 — Referral bonus-eligibility hook (read-only trigger // check, then a narrow, single-entity update inside ReferralBLL) ─────────── // Reuses the same "genuine Hired" structural signal as CandidateConversionBLL's // class doc comment: current stage is Terminal+Hired category (StageCategory=7, // IsTerminal=1) AND this specific stage-history row's own Outcome is Passed(2). // Unlike CandidateConversionBLL (which runs later, at an independent HR-triggered // conversion step, and has to look up the most recent stage-history row for an // Application whose "current" stage is already known), this hook runs at the // moment the stage-history row itself is written, so the Outcome is simply the // value already on ApplicationStageHistoryDTO — no extra history lookup needed. // A referral-eligibility check must never fail the underlying stage-advance save, // same non-fatal posture as the Quiz check above and the Hub push below. try { if (ApplicationStageHistoryDTO.Outcome == OUTCOME_PASSED_FOR_REFERRAL && currentApplication != null && currentApplication.CandidateId > 0) { bool isTerminalHire = await _SelectionProcessBLL .IsSelectionProcessStageTerminalHireCategory(ApplicationStageHistoryDTO.SelectionProcessStageId, LoginDTO, ct) .ConfigureAwait(false); if (isTerminalHire) { GB5Trace.Step("referral-bonus-eligibility-check", new { ApplicationStageHistoryDTO.ApplicationId, currentApplication.CandidateId }); await _ReferralBLL .MarkEligibleOnHireAsync(currentApplication.CandidateId, LoginDTO, ct) .ConfigureAwait(false); } } } catch (Exception ex) { GB5Trace.MarkFailed("applicationstagehistory-referral-eligibility-check-failed", ex); _Logger.LogWarning(ex, "Referral bonus-eligibility check failed for ApplicationId {ApplicationId}", ApplicationStageHistoryDTO.ApplicationId); } // ── Live push: kanban board watchers move the card without polling ─────── try { await _HubNotifier.NotifyApplicationStageChangedAsync( ApplicationStageHistoryDTO.ApplicationId, jobRequisitionId, oldStageId, ApplicationStageHistoryDTO.SelectionProcessStageId, LoginDTO.ClientId, ct).ConfigureAwait(false); } catch (Exception ex) { // A SignalR push failure must never fail the underlying save/HTTP response. GB5Trace.MarkFailed("applicationstagehistory-hub-push-failed", ex); _Logger.LogWarning(ex, "Failed to push ApplicationStageChanged for ApplicationId {ApplicationId}", ApplicationStageHistoryDTO.ApplicationId); } var applicationCacheKey = new CacheKeyGeneration().KeyGeneration( ApplicationStageHistoryDTO.ApplicationId, EntityConstant.OBJECTRECRUITMENTAPPLICATION, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(applicationCacheKey).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( ApplicationStageHistoryDTO.ApplicationStageHistoryId, EntityConstant.OBJECTRECRUITMENTAPPLICATIONSTAGEHISTORY, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation( "ApplicationStageHistory {ApplicationStageHistoryId} {Action} by user {UserId}; Application {ApplicationId} advanced to stage {StageId}", ApplicationStageHistoryDTO.ApplicationStageHistoryId, isNew ? "saved" : "updated", LoginDTO.UserId, ApplicationStageHistoryDTO.ApplicationId, ApplicationStageHistoryDTO.SelectionProcessStageId); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {ApplicationStageHistoryDTO.ApplicationStageHistoryId}" : $"{SuccessResponse.UpdateSuccessMessage} {ApplicationStageHistoryDTO.ApplicationStageHistoryId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-applicationstagehistory-failed", vex); if (autoNumberDTO != null && ApplicationStageHistoryDTO.ApplicationStageHistoryId > 0) await _AutoNumber.RollbackAutoNumber("APPLICATIONSTAGEHISTORY", ApplicationStageHistoryDTO.ApplicationStageHistoryId, LoginDTO).ConfigureAwait(false); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-applicationstagehistory-failed", ex); _Logger.LogError(ex, "SaveApplicationStageHistory failed for ApplicationStageHistoryId {ApplicationStageHistoryId}", ApplicationStageHistoryDTO.ApplicationStageHistoryId); if (autoNumberDTO != null && ApplicationStageHistoryDTO.ApplicationStageHistoryId > 0) { await _AutoNumber.RollbackAutoNumber( "APPLICATIONSTAGEHISTORY", ApplicationStageHistoryDTO.ApplicationStageHistoryId, LoginDTO).ConfigureAwait(false); } throw; } } #endregion #region Select List public async Task GetSelectListApplicationStageHistory(CriteriaDTO criteriaDTO, LoginDTO LoginDTO, CancellationToken ct) { try { return await _ApplicationDAL.GetSelectListApplicationStageHistory(criteriaDTO, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-selectlist-applicationstagehistory-failed", ex); _Logger.LogError(ex, "GetSelectListApplicationStageHistory failed"); throw; } } #endregion #region Delete public async Task DeleteApplicationStageHistory(int ApplicationStageHistoryId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("delete-applicationstagehistory", new { ApplicationStageHistoryId }); var result = await _ApplicationDAL.DeleteApplicationStageHistory(ApplicationStageHistoryId, LoginDTO, ct) .ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.DELETERECRUITMENTAPPLICATIONSTAGEHISTORYEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "ApplicationStageHistory Deleted", new { ApplicationStageHistoryId }, EventTypeConstant.DELETERECRUITMENTAPPLICATIONSTAGEHISTORYEVENTTYPEID, ApplicationStageHistoryId, LoginDTO, ct: ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( ApplicationStageHistoryId, EntityConstant.OBJECTRECRUITMENTAPPLICATIONSTAGEHISTORY, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation("ApplicationStageHistory {ApplicationStageHistoryId} deleted by user {UserId}", ApplicationStageHistoryId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-applicationstagehistory-failed", ex); _Logger.LogError(ex, "DeleteApplicationStageHistory failed for ApplicationStageHistoryId {ApplicationStageHistoryId}", ApplicationStageHistoryId); throw; } } #endregion #region Quiz Screening (cross-module read against TMS's shared Quiz engine) // Read-only: resolves the current title/definition of the Quiz a stage optionally // requires. Direct BLL-to-BLL call into TMSBLL.Quiz.IQuizBLL — same pattern as // EnablementBLL.ShowContentLinkBLL's live-title-refresh call against the identical // interface (GetQuizById), both possible because RecruitmentSL and TMSSL are // HostedBy the same host (HRFinanceHost), so this is a same-process DI call, not HTTP. // // Does not touch TQUIZATTEMPT — see this method's declaration in IApplicationBLL for // why an actual Candidate attempt can't be created yet. public async Task GetRequiredQuizForStage(int SelectionProcessStageId, LoginDTO LoginDTO, CancellationToken ct) { try { int quizId = await _SelectionProcessBLL .GetSelectionProcessStageQuizId(SelectionProcessStageId, LoginDTO, ct) .ConfigureAwait(false); if (quizId <= 0) return JsonConvert.SerializeObject(new { QuizRequired = false }); var quizJson = await _QuizBLL.GetQuizById(quizId, LoginDTO).ConfigureAwait(false); var quiz = JsonConvert.DeserializeObject(quizJson); return JsonConvert.SerializeObject(new { QuizRequired = true, QuizId = quizId, Quiz = quiz }); } catch (Exception ex) { GB5Trace.MarkFailed("get-requiredquizforstage-failed", ex); _Logger.LogError(ex, "GetRequiredQuizForStage failed for SelectionProcessStageId {SelectionProcessStageId}", SelectionProcessStageId); throw; } } #endregion #region ECP Comment Thread Integration // Thin passthroughs to EcpIntegrationService (ECP is hosted by a different process — // EngagementHost — so this is an HTTP call, not a direct BLL reference; see // IEcpIntegrationService's class doc comment). No CommentThread data is persisted in // Recruitment's own schema — TCOMMENTTHREAD lives entirely in ECP, keyed by // ObjectTypeId=EntityConstant.OBJECTRECRUITMENTAPPLICATION / ObjectId=ApplicationId. public async Task PostApplicationComment(int ApplicationId, string CommentText, List MentionedUserIds, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("post-application-comment", new { ApplicationId }); var outcome = await _EcpIntegrationService .PostApplicationCommentAsync(ApplicationId, CommentText, MentionedUserIds ?? new List(), LoginDTO, ct) .ConfigureAwait(false); if (!outcome.Success) { _Logger.LogWarning( "PostApplicationComment: ECP call did not succeed for ApplicationId {ApplicationId}: {Error}", ApplicationId, outcome.Error); return ErrorResponse.SaveErrorMessage; } _Logger.LogInformation("Comment posted on Application {ApplicationId} by user {UserId}", ApplicationId, LoginDTO.UserId); return SuccessResponse.SaveSuccess; } catch (Exception ex) { GB5Trace.MarkFailed("post-application-comment-failed", ex); _Logger.LogError(ex, "PostApplicationComment failed for ApplicationId {ApplicationId}", ApplicationId); throw; } } public async Task GetApplicationComments(int ApplicationId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("get-application-comments", new { ApplicationId }); var body = await _EcpIntegrationService .GetApplicationCommentsAsync(ApplicationId, LoginDTO, ct) .ConfigureAwait(false); // ECP unreachable/failed — degrade to an empty list rather than surfacing a 500, // per the graceful-degradation contract this integration is built around. return body ?? "[]"; } catch (Exception ex) { GB5Trace.MarkFailed("get-application-comments-failed", ex); _Logger.LogError(ex, "GetApplicationComments failed for ApplicationId {ApplicationId}", ApplicationId); throw; } } #endregion } }