using System; using System.ComponentModel.DataAnnotations; using System.Threading; 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.Application; using RecruitmentBLL.Candidate; using RecruitmentBLL.Common; using RecruitmentBLL.Integration; using RecruitmentDAL.CustomeCode.Interview; using RecruitmentDAL.DTO.Application; using RecruitmentDAL.DTO.Candidate; using RecruitmentDAL.DTO.Interview; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.Interview { public class InterviewBLL : IInterviewBLL { // INTERVIEWMODE values (see TINTERVIEW.INTERVIEWMODE / 20260903_Recruitment_Phase2_ // InterviewPanelScorecard_Schema_SqlServer.sql): 1=InPerson 2=Phone 3=VideoLive // 4=VideoAsync. Only the two video modes get an ECP meeting scheduled. private const byte InterviewModeVideoLive = 3; private const byte InterviewModeVideoAsync = 4; private readonly IInterviewDAL _InterviewDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly BaseEntityAppService _BaseEntityAppService; private readonly KeyInvalidate _KeyInvalidate; private readonly EventLogPublish _EventLog; private readonly IRecruitmentHubNotifier _HubNotifier; private readonly IEcpIntegrationService _EcpIntegrationService; private readonly IApplicationBLL _ApplicationBLL; private readonly ICandidateBLL _CandidateBLL; private readonly ILogger _Logger; public InterviewBLL( IInterviewDAL InterviewDAL, AutoNumber AutoNumber, IValidation validation, BaseEntityAppService baseEntityAppService, KeyInvalidate keyInvalidate, EventLogPublish eventLog, IRecruitmentHubNotifier hubNotifier, IEcpIntegrationService ecpIntegrationService, IApplicationBLL applicationBLL, ICandidateBLL candidateBLL, ILogger logger) { _InterviewDAL = InterviewDAL; _AutoNumber = AutoNumber; _Validation = validation; _BaseEntityAppService = baseEntityAppService; _KeyInvalidate = keyInvalidate; _EventLog = eventLog; _HubNotifier = hubNotifier; _EcpIntegrationService = ecpIntegrationService; _ApplicationBLL = applicationBLL; _CandidateBLL = candidateBLL; _Logger = logger; } public async Task GetInterview(int InterviewId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("get-interview", new { InterviewId }); var result = await _InterviewDAL.GetInterview(InterviewId, LoginDTO, ct).ConfigureAwait(false); _Logger.LogInformation("Interview {InterviewId} fetched by user {UserId}", InterviewId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("get-interview-failed", ex); _Logger.LogError(ex, "GetInterview failed for InterviewId {InterviewId}", InterviewId); throw; } } public async Task SaveInterview(InterviewDTO InterviewDTO, LoginDTO LoginDTO, CancellationToken ct) { if (InterviewDTO == null) throw new ArgumentNullException(nameof(InterviewDTO)); AutoNumberDTO? autoNumberDTO = null; try { GB5Trace.Step("validate-interview", new { InterviewDTO.InterviewId }); await _Validation .Range((short)1, short.MaxValue, InterviewDTO.DurationMinutes, nameof(InterviewDTO.DurationMinutes)) .ConfigureAwait(false); InterviewDTO.ModifiedById = LoginDTO.UserId; InterviewDTO.ModifiedOn = DateTime.UtcNow; bool isNew = InterviewDTO.InterviewId == 0; if (isNew) { // Entity-code casing MUST match the uppercase MAUTONUMBER seed row // ("INTERVIEW") — Postgres AutoNumber lookups are case-sensitive, so the // previously-commented "Interview" mixed-case literal would silently fail // to resolve a row on that provider. autoNumberDTO = await _AutoNumber .GetNumberAsync(1, "INTERVIEW", LoginDTO) .ConfigureAwait(false); InterviewDTO.InterviewId = autoNumberDTO.StartNumber; InterviewDTO.CreatedById = LoginDTO.UserId; InterviewDTO.CreatedOn = DateTime.UtcNow; } int eventTypeId = isNew ? EventTypeConstant.SAVERECRUITMENTINTERVIEWEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTINTERVIEWEVENTTYPEID; GB5Trace.Step("save-interview", new { InterviewDTO.InterviewId, isNew }); await _BaseEntityAppService.ExecuteSaveAsync( InterviewDTO.InterviewId, eventTypeId, InterviewDTO, LoginDTO, persistFunc: tx => isNew ? _InterviewDAL.SaveInterview(InterviewDTO, LoginDTO, tx, ct) : _InterviewDAL.UpdateInterview(InterviewDTO, LoginDTO, tx, ct), isNewEntity: isNew).ConfigureAwait(false); // ExecuteSaveAsync already performs the Dapr direct-publish + outbox write for // this eventTypeId internally (see BaseEntityAppService.ExecuteSaveAsync) — this // step just marks the point on the trace where that publish happened, it does not // duplicate it with a second EventLogPublish call. GB5Trace.Step("event-publish", new { EventTypeId = eventTypeId }); var cacheKey = new CacheKeyGeneration().KeyGeneration( InterviewDTO.InterviewId, EntityConstant.OBJECTRECRUITMENTINTERVIEW, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); // ── Live push: assigned interviewer sees the new/rescheduled interview ──── try { await _HubNotifier.NotifyInterviewScheduledAsync( InterviewDTO.InterviewId, InterviewDTO.ApplicationId, InterviewDTO.InterviewerId, InterviewDTO.InterviewDate, InterviewDTO.InterviewTime, InterviewDTO.DurationMinutes, isReschedule: !isNew, ct: ct).ConfigureAwait(false); } catch (Exception ex) { // A SignalR push failure must never fail the underlying save/HTTP response. GB5Trace.MarkFailed("interview-hub-push-failed", ex); _Logger.LogWarning(ex, "Failed to push InterviewScheduled for InterviewId {InterviewId}", InterviewDTO.InterviewId); } // ── ECP integration: schedule a real Meeting for video interviews ───────── // VideoLive/VideoAsync interviews get a TMEETING row + lead-interviewer // participant via ECP (hosted by a different process — EngagementHost). This is // deliberately AFTER the interview save has already committed and is fully // non-blocking: an ECP outage must never fail an Interview save (see // IEcpIntegrationService's class doc comment for the graceful-degradation // contract). The follow-up TINTERVIEW.ECPMEETINGID write is its own small, // non-transactional UPDATE — same pattern as // IApplicationDAL.UpdateApplicationCurrentStage. if (InterviewDTO.InterviewMode == InterviewModeVideoLive || InterviewDTO.InterviewMode == InterviewModeVideoAsync) { try { GB5Trace.Step("ecp-schedule-meeting", new { InterviewDTO.InterviewId, InterviewDTO.InterviewMode }); var scheduleOutcome = await _EcpIntegrationService .ScheduleMeetingAsync(InterviewDTO, LoginDTO, ct) .ConfigureAwait(false); if (scheduleOutcome.Success) { await _InterviewDAL .UpdateInterviewEcpMeetingId(InterviewDTO.InterviewId, scheduleOutcome.MeetingId, LoginDTO, ct) .ConfigureAwait(false); InterviewDTO.EcpMeetingId = scheduleOutcome.MeetingId; } else { _Logger.LogWarning( "ECP ScheduleMeetingAsync did not succeed for InterviewId {InterviewId}: {Error}", InterviewDTO.InterviewId, scheduleOutcome.Error); } } catch (Exception ex) { // Belt-and-braces: EcpIntegrationService already catches/degrades // internally, but the interview save must survive regardless. GB5Trace.MarkFailed("ecp-schedule-meeting-failed", ex); _Logger.LogWarning(ex, "ECP meeting scheduling failed for InterviewId {InterviewId}", InterviewDTO.InterviewId); } } // ── ECP integration: send an interview invite via Correspondence ───────── // Additional to (not a replacement for) the video-Meeting scheduling above — an // in-person/phone interview never gets a TMEETING row but still needs a candidate // notification, and a video interview needs both. Deliberately AFTER the interview // save has committed and fully non-blocking, same graceful-degradation contract as // the meeting-scheduling block above. try { GB5Trace.Step("ecp-send-interview-invite", new { InterviewDTO.InterviewId, InterviewDTO.ApplicationId }); var recipientEmail = await ResolveApplicantEmailAsync(InterviewDTO.ApplicationId, LoginDTO, ct).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(recipientEmail)) { var sendOutcome = await _EcpIntegrationService .SendInterviewInviteAsync(InterviewDTO, recipientEmail, LoginDTO, ct) .ConfigureAwait(false); if (!sendOutcome.Success) _Logger.LogWarning( "ECP SendInterviewInviteAsync did not succeed for InterviewId {InterviewId}: {Error}", InterviewDTO.InterviewId, sendOutcome.Error); } else { _Logger.LogWarning( "Interview invite not sent for InterviewId {InterviewId} — no applicant email resolved for ApplicationId {ApplicationId}", InterviewDTO.InterviewId, InterviewDTO.ApplicationId); } } catch (Exception ex) { // Belt-and-braces: EcpIntegrationService already catches/degrades internally, // but the interview save must survive regardless. GB5Trace.MarkFailed("ecp-send-interview-invite-failed", ex); _Logger.LogWarning(ex, "ECP interview-invite send failed for InterviewId {InterviewId}", InterviewDTO.InterviewId); } _Logger.LogInformation("Interview {InterviewId} saved by user {UserId}", InterviewDTO.InterviewId, LoginDTO.UserId); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {InterviewDTO.InterviewId}" : $"{SuccessResponse.UpdateSuccessMessage} {InterviewDTO.InterviewId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-interview-failed", vex); _Logger.LogError(vex, "SaveInterview validation failed for InterviewId {InterviewId}", InterviewDTO.InterviewId); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-interview-failed", ex); _Logger.LogError(ex, "SaveInterview failed for InterviewId {InterviewId}", InterviewDTO.InterviewId); if (autoNumberDTO != null && InterviewDTO.InterviewId > 0) await _AutoNumber.RollbackAutoNumber("INTERVIEW", InterviewDTO.InterviewId, LoginDTO).ConfigureAwait(false); throw; } } public async Task GetSelectListInterview(CriteriaDTO criteriaDTO, LoginDTO LoginDTO, CancellationToken ct) { try { return await _InterviewDAL.GetSelectListInterview(criteriaDTO, LoginDTO, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-selectlist-interview-failed", ex); _Logger.LogError(ex, "GetSelectListInterview failed"); throw; } } public async Task DeleteInterview(int InterviewId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("delete-interview", new { InterviewId }); var result = await _InterviewDAL.DeleteInterview(InterviewId, LoginDTO, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.DELETERECRUITMENTINTERVIEWEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Interview Deleted", new { InterviewId }, EventTypeConstant.DELETERECRUITMENTINTERVIEWEVENTTYPEID, InterviewId, LoginDTO, ct: ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( InterviewId, EntityConstant.OBJECTRECRUITMENTINTERVIEW, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation("Interview {InterviewId} deleted by user {UserId}", InterviewId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-interview-failed", ex); _Logger.LogError(ex, "DeleteInterview failed for InterviewId {InterviewId}", InterviewId); throw; } } // Resolves InterviewDTO.ApplicationId -> ApplicationDTO.CandidateId -> CandidateDTO.Email // via the existing Application/Candidate BLLs — same BLL-to-BLL pattern and same-shape // helper as JobOfferBLL.ResolveApplicantEmailAsync. GetApplication/GetCandidate both // return a JsonConvert.SerializeObject(...) string per their own DAL implementations, so // deserialize with Newtonsoft to match. Returns null (not throws) on any missing link. private async Task ResolveApplicantEmailAsync(int applicationId, LoginDTO login, CancellationToken ct) { if (applicationId <= 0) return null; var applicationJson = await _ApplicationBLL.GetApplication(applicationId, login, ct).ConfigureAwait(false); var application = JsonConvert.DeserializeObject(applicationJson); if (application is null || application.CandidateId <= 0) return null; var candidateJson = await _CandidateBLL.GetCandidate(application.CandidateId, login, ct).ConfigureAwait(false); var candidate = JsonConvert.DeserializeObject(candidateJson); return candidate?.Email; } } }