using System.Net.Http.Json; using System.Text.Json; using System.Text.RegularExpressions; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using RecruitmentDAL.CustomeCode.Integration; using RecruitmentDAL.DTO.Application; using RecruitmentDAL.DTO.Interview; using RecruitmentDAL.DTO.JobOffer; using RecruitmentDAL.DTO.JobRequisition; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.Integration { /// Fixed MCORRESPONDENCETYPE ids seeded by /// DB/Migrations/20260903_Recruitment_Phase3_CorrespondenceTypeSeed_{SqlServer,Postgres}.sql — /// keep these in sync with that migration if it's ever renumbered. internal static class RecruitmentCorrespondenceTypes { public const int OfferLetter = 4; // RECRUITMENT_OFFER_LETTER public const int InterviewInvite = 5; // RECRUITMENT_INTERVIEW_INVITE public const int RejectionLetter = 6; // RECRUITMENT_REJECTION_LETTER public const int HireNotification = 7; // RECRUITMENT_HIRE_NOTIFICATION } /// public class EcpIntegrationService : IEcpIntegrationService { private readonly IHttpClientFactory _HttpClientFactory; private readonly IUserLookupDAL _UserLookupDAL; private readonly IConfiguration _Configuration; private readonly ILogger _Logger; // Extracts the trailing "... with id = {id}" number every GB5 SuccessResponse. // SaveSuccessMessage-style string carries (see GB5Shared/Resource/Response/ // SuccessResponse.resx) — same "last integer token" convention MeetingBLL.SaveMeetingAsync // returns ($"{SuccessResponse.SaveSuccessMessage} {newId}"). private static readonly Regex TrailingIdRegex = new(@"(-?\d+)\s*$", RegexOptions.Compiled); public EcpIntegrationService( IHttpClientFactory httpClientFactory, IUserLookupDAL userLookupDAL, IConfiguration configuration, ILogger logger) { _HttpClientFactory = httpClientFactory; _UserLookupDAL = userLookupDAL; _Configuration = configuration; _Logger = logger; } public async Task ScheduleMeetingAsync(InterviewDTO interview, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("ecp-schedule-meeting", new { interview.InterviewId, interview.InterviewerId }); var client = BuildClient(login); // SaveMeetingDTO shape (ECPDAL.Meeting.DTOs) posted directly at top level — a // [FromBody] record property binds the raw top-level body, never a wrapper // (repo-confirmed gotcha — see ECPSL.Meeting.Parameters.SaveMeetingParameters). var startOn = interview.InterviewDate.Date.AddMinutes(interview.InterviewTime); var meetingPayload = new { MeetingId = 0, Title = $"Interview #{interview.InterviewId}", MeetingTypeId = -1, StartOn = startOn, EndOn = startOn.AddMinutes(interview.DurationMinutes > 0 ? interview.DurationMinutes : 30), VirtualLink = (string?)null, Location = (string?)null, ObjectTypeId = EntityConstant.OBJECTRECRUITMENTINTERVIEW, ObjectId = interview.InterviewId, OuId = -1 }; var saveResponse = await client.PostAsJsonAsync("Meeting/SaveMeeting", meetingPayload, ct) .ConfigureAwait(false); if (!saveResponse.IsSuccessStatusCode) { var body = await saveResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false); _Logger.LogWarning( "ECP SaveMeeting returned {StatusCode} for InterviewId {InterviewId}: {Body}", saveResponse.StatusCode, interview.InterviewId, body); return new EcpMeetingScheduleOutcome { Success = false, Error = $"ECP returned {(int)saveResponse.StatusCode}" }; } var meetingId = await ExtractTrailingIdAsync(saveResponse, ct).ConfigureAwait(false); if (meetingId is null) { _Logger.LogWarning("ECP SaveMeeting succeeded but MeetingId could not be parsed for InterviewId {InterviewId}", interview.InterviewId); return new EcpMeetingScheduleOutcome { Success = false, Error = "MeetingId not parseable from ECP response" }; } // Add the lead interviewer as a participant. Panel-member fan-out // (TINTERVIEWPANEL) is a fast-follow once that entity exists from a parallel // task — not invented here per the plan's explicit scope note. var participantPayload = new { MeetingId = meetingId.Value, UserId = interview.InterviewerId }; var participantResponse = await client .PostAsJsonAsync("MeetingDetails/AddMeetingParticipant", participantPayload, ct) .ConfigureAwait(false); if (!participantResponse.IsSuccessStatusCode) { var body = await participantResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false); // Meeting itself was created successfully — degrade gracefully rather than // treating the whole schedule as failed; the caller still gets a MeetingId. _Logger.LogWarning( "ECP AddMeetingParticipant returned {StatusCode} for MeetingId {MeetingId}, InterviewerId {InterviewerId}: {Body}", participantResponse.StatusCode, meetingId.Value, interview.InterviewerId, body); } return new EcpMeetingScheduleOutcome { Success = true, MeetingId = meetingId.Value }; } catch (Exception ex) { GB5Trace.MarkFailed("ecp-schedule-meeting-failed", ex); _Logger.LogError(ex, "ECP ScheduleMeetingAsync failed for InterviewId {InterviewId}", interview.InterviewId); return new EcpMeetingScheduleOutcome { Success = false, Error = ex.Message }; } } public async Task AttachInterviewRecordingAsync(int interviewId, int recordingAttachmentId, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("ecp-attach-recording", new { interviewId, recordingAttachmentId }); var client = BuildClient(login); // AttachRecordingDTO shape posted directly at top level. var payload = new { ObjectTypeId = EntityConstant.OBJECTRECRUITMENTINTERVIEW, ObjectId = interviewId, RecordingAttachmentId = recordingAttachmentId }; var response = await client.PostAsJsonAsync("MeetingRecording/AttachRecording", payload, ct) .ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); _Logger.LogWarning( "ECP AttachRecording returned {StatusCode} for InterviewId {InterviewId}: {Body}", response.StatusCode, interviewId, body); return new EcpCallOutcome { Success = false, Error = $"ECP returned {(int)response.StatusCode}" }; } return new EcpCallOutcome { Success = true }; } catch (Exception ex) { GB5Trace.MarkFailed("ecp-attach-recording-failed", ex); _Logger.LogError(ex, "ECP AttachInterviewRecordingAsync failed for InterviewId {InterviewId}", interviewId); return new EcpCallOutcome { Success = false, Error = ex.Message }; } } public async Task GetInterviewRecordingsAsync(int interviewId, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("ecp-get-interview-recordings", new { interviewId }); var client = BuildClient(login); var objectTypeId = EntityConstant.OBJECTRECRUITMENTINTERVIEW; var response = await client .GetAsync($"MeetingRecording/GetRecordingTranscriptsForObject?ObjectTypeId={objectTypeId}&ObjectId={interviewId}", ct) .ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); _Logger.LogWarning( "ECP GetRecordingTranscriptsForObject returned {StatusCode} for InterviewId {InterviewId}: {Body}", response.StatusCode, interviewId, body); return null; } return await ExtractBodyAsync(response, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("ecp-get-interview-recordings-failed", ex); _Logger.LogError(ex, "ECP GetInterviewRecordingsAsync failed for InterviewId {InterviewId}", interviewId); return null; } } public async Task PostApplicationCommentAsync(int applicationId, string commentText, List mentionedUserIds, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("ecp-post-application-comment", new { applicationId }); var client = BuildClient(login); // SaveCommentThreadDTO shape posted directly at top level. var payload = new { CommentThreadId = 0, ParentCommentThreadId = -1, ObjectTypeId = EntityConstant.OBJECTRECRUITMENTAPPLICATION, ObjectId = applicationId, CommentText = commentText, AccessType = (byte)1, PrivateUserGroupId = -1, SecurityMarkId = -1, BizTransactionId = -1, OuId = -1, MentionedUserIds = mentionedUserIds ?? new List() }; var response = await client.PostAsJsonAsync("CommentThread/SaveCommentThread", payload, ct) .ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); _Logger.LogWarning( "ECP SaveCommentThread returned {StatusCode} for ApplicationId {ApplicationId}: {Body}", response.StatusCode, applicationId, body); return new EcpCallOutcome { Success = false, Error = $"ECP returned {(int)response.StatusCode}" }; } return new EcpCallOutcome { Success = true }; } catch (Exception ex) { GB5Trace.MarkFailed("ecp-post-application-comment-failed", ex); _Logger.LogError(ex, "ECP PostApplicationCommentAsync failed for ApplicationId {ApplicationId}", applicationId); return new EcpCallOutcome { Success = false, Error = ex.Message }; } } public async Task GetApplicationCommentsAsync(int applicationId, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("ecp-get-application-comments", new { applicationId }); var client = BuildClient(login); var objectTypeId = EntityConstant.OBJECTRECRUITMENTAPPLICATION; var response = await client .GetAsync($"CommentThread/GetCommentThreadTree?ObjectTypeId={objectTypeId}&ObjectId={applicationId}", ct) .ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); _Logger.LogWarning( "ECP GetCommentThreadTree returned {StatusCode} for ApplicationId {ApplicationId}: {Body}", response.StatusCode, applicationId, body); return null; } return await ExtractBodyAsync(response, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("ecp-get-application-comments-failed", ex); _Logger.LogError(ex, "ECP GetApplicationCommentsAsync failed for ApplicationId {ApplicationId}", applicationId); return null; } } // ── Phase 3: Correspondence (offer/interview/rejection letters) ──────────────────── public async Task SendOfferLetterAsync(JobOfferDTO jobOffer, string recipientEmail, LoginDTO login, CancellationToken ct) { GB5Trace.Step("ecp-send-offer-letter", new { jobOffer.JobOfferId }); // Merge fields drawn directly from JobOfferDTO's own columns — Position is the // JobPositionId FK, not a resolved title (no cross-module JobPosition lookup here). var fields = new Dictionary { ["JobOfferId"] = jobOffer.JobOfferId, ["CTC"] = jobOffer.CTC, ["ExpectedJoiningDate"] = jobOffer.ExpectedJoiningDate, ["OfferExpiryDate"] = jobOffer.OfferExpiryDate, ["Position"] = jobOffer.JobPositionId, ["EmployeementType"] = jobOffer.EmployeementType, ["ProbationPeriod"] = jobOffer.ProbationPeriod, }; return await SendCorrespondenceAsync( objectTypeId: EntityConstant.OBJECTRECRUITMENTJOBOFFER, objectId: jobOffer.JobOfferId, correspondenceTypeId: RecruitmentCorrespondenceTypes.OfferLetter, templateConfigKey: "OfferLetter", subject: "Your Job Offer", recipientEmail: recipientEmail, fields: fields, login: login, ct: ct).ConfigureAwait(false); } public async Task SendInterviewInviteAsync(InterviewDTO interview, string recipientEmail, LoginDTO login, CancellationToken ct) { GB5Trace.Step("ecp-send-interview-invite", new { interview.InterviewId }); var fields = new Dictionary { ["InterviewId"] = interview.InterviewId, ["InterviewDate"] = interview.InterviewDate, ["InterviewTime"] = interview.InterviewTime, ["DurationMinutes"] = interview.DurationMinutes, ["LocationId"] = interview.LocationId, ["InterviewMode"] = interview.InterviewMode, ["Position"] = interview.JobPositionId, }; return await SendCorrespondenceAsync( objectTypeId: EntityConstant.OBJECTRECRUITMENTINTERVIEW, objectId: interview.InterviewId, correspondenceTypeId: RecruitmentCorrespondenceTypes.InterviewInvite, templateConfigKey: "InterviewInvite", subject: "Interview Invitation", recipientEmail: recipientEmail, fields: fields, login: login, ct: ct).ConfigureAwait(false); } public async Task SendRejectionLetterAsync(ApplicationDTO application, string recipientEmail, LoginDTO login, CancellationToken ct) { GB5Trace.Step("ecp-send-rejection-letter", new { application.ApplicationId }); var fields = new Dictionary { ["ApplicationId"] = application.ApplicationId, ["JobRequisitionId"] = application.JobRequisitionId, ["AppliedOn"] = application.AppliedOn, }; return await SendCorrespondenceAsync( objectTypeId: EntityConstant.OBJECTRECRUITMENTAPPLICATION, objectId: application.ApplicationId, correspondenceTypeId: RecruitmentCorrespondenceTypes.RejectionLetter, templateConfigKey: "RejectionLetter", subject: "Application Status Update", recipientEmail: recipientEmail, fields: fields, login: login, ct: ct).ConfigureAwait(false); } public async Task NotifyRequesterOnHireAsync(JobRequisitionDTO jobRequisition, string hiredCandidateName, LoginDTO login, CancellationToken ct) { GB5Trace.Step("ecp-notify-requester-on-hire", new { jobRequisition.JobRequisitionId, jobRequisition.RequestedById }); string? requesterEmail; try { requesterEmail = await _UserLookupDAL.GetUserEmail(jobRequisition.RequestedById, login, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("ecp-notify-requester-on-hire-user-lookup-failed", ex); _Logger.LogWarning(ex, "Failed to resolve requester email for JobRequisitionId {JobRequisitionId}, RequestedById {RequestedById}", jobRequisition.JobRequisitionId, jobRequisition.RequestedById); return new EcpCallOutcome { Success = false, Error = "Requester email lookup failed" }; } var fields = new Dictionary { ["JobRequisitionId"] = jobRequisition.JobRequisitionId, ["RequisitionCode"] = jobRequisition.RequisitionCode, ["Position"] = jobRequisition.JobPositionId, ["HiredCandidateName"] = hiredCandidateName, ["TargetJoiningDate"] = jobRequisition.TargetJoiningDate, }; return await SendCorrespondenceAsync( objectTypeId: EntityConstant.OBJECTRECRUITMENTJOBREQUISITION, objectId: jobRequisition.JobRequisitionId, correspondenceTypeId: RecruitmentCorrespondenceTypes.HireNotification, templateConfigKey: "HireNotification", subject: "Your Requested Position Has Been Filled", recipientEmail: requesterEmail ?? string.Empty, fields: fields, login: login, ct: ct).ConfigureAwait(false); } /// Shared POST /Correspondence/SaveAndSendCorrespondence caller for all four /// Recruitment letter types. Degrades to Success=false — without an HTTP call — when /// either the per-type TemplateId isn't configured yet (Integration:CorrespondenceTemplates: /// {templateConfigKey} in appsettings.json, an ECP TATTACHMENT id of an uploaded .docx/.xlsx /// template) or no recipient email was resolved, since SaveAndSendCorrespondence's own BLL /// validation would otherwise throw ("TemplateId is required." / ToIds NotEmpty) and turn /// into a guaranteed failed HTTP call. private async Task SendCorrespondenceAsync( int objectTypeId, int objectId, int correspondenceTypeId, string templateConfigKey, string subject, string recipientEmail, Dictionary fields, LoginDTO login, CancellationToken ct) { var templateId = _Configuration.GetValue($"Integration:CorrespondenceTemplates:{templateConfigKey}", -1); if (templateId <= 0) { _Logger.LogWarning( "ECP correspondence not sent for ObjectTypeId {ObjectTypeId} ObjectId {ObjectId} — " + "Integration:CorrespondenceTemplates:{TemplateConfigKey} is not configured.", objectTypeId, objectId, templateConfigKey); return new EcpCallOutcome { Success = false, Error = $"CorrespondenceTemplate '{templateConfigKey}' not configured" }; } if (string.IsNullOrWhiteSpace(recipientEmail)) { _Logger.LogWarning( "ECP correspondence not sent for ObjectTypeId {ObjectTypeId} ObjectId {ObjectId} — no recipient email resolved.", objectTypeId, objectId); return new EcpCallOutcome { Success = false, Error = "Recipient email not resolved" }; } try { var client = BuildClient(login); // SaveAndSendCorrespondenceDTO shape (ECPDAL.Correspondence.DTOs) posted directly // at top level — same "[FromBody] record property binds the raw top-level body" // gotcha as every other EcpIntegrationService call. Mode=1 mirrors // ECPBLL.Correspondence.CorrespondenceConstants.Mode.Mail (no ProjectReference to // ECPBLL exists across hosts, so the literal is repeated here with this comment). var payload = new { ObjectTypeId = objectTypeId, ObjectId = objectId, CorrespondenceTypeId = correspondenceTypeId, TemplateId = templateId, Fields = fields, Tables = new Dictionary>>(), Mode = (byte)1, Subject = subject, FromId = (string?)null, ToIds = recipientEmail, CcIds = (string?)null, BccIds = (string?)null, BizTransactionTypeId = -1, BizTransactionId = -1, OuId = -1 }; var response = await client.PostAsJsonAsync("Correspondence/SaveAndSendCorrespondence", payload, ct) .ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); _Logger.LogWarning( "ECP SaveAndSendCorrespondence returned {StatusCode} for ObjectTypeId {ObjectTypeId} ObjectId {ObjectId}: {Body}", response.StatusCode, objectTypeId, objectId, body); return new EcpCallOutcome { Success = false, Error = $"ECP returned {(int)response.StatusCode}" }; } return new EcpCallOutcome { Success = true }; } catch (Exception ex) { GB5Trace.MarkFailed("ecp-send-correspondence-failed", ex); _Logger.LogError(ex, "ECP SendCorrespondence failed for ObjectTypeId {ObjectTypeId} ObjectId {ObjectId}", objectTypeId, objectId); return new EcpCallOutcome { Success = false, Error = ex.Message }; } } // ── Helpers ────────────────────────────────────────────────────── private HttpClient BuildClient(LoginDTO login) { var client = _HttpClientFactory.CreateClient("ECPModule"); client.DefaultRequestHeaders.Remove("Login"); client.DefaultRequestHeaders.Add("Login", JsonSerializer.Serialize(login)); return client; } /// ECP's ResponseStandardDTO<object>.Body carries the BLL's own /// JSON-serialized string result (property naming policy is null/preserve-case, per /// GB5Shared.ResponseStandard.Response.CreateSuccessResponse) — read it permissively, /// same best-effort approach as MmIntegrationService.TryExtractId. private async Task ExtractTrailingIdAsync(HttpResponseMessage response, CancellationToken ct) { var body = await ExtractBodyAsync(response, ct).ConfigureAwait(false); if (body is null) return null; var match = TrailingIdRegex.Match(body); return match.Success && int.TryParse(match.Groups[1].Value, out var id) ? id : null; } private static async Task ExtractBodyAsync(HttpResponseMessage response, CancellationToken ct) { try { var raw = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); using var doc = JsonDocument.Parse(raw); if (doc.RootElement.TryGetProperty("Body", out var bodyElement)) { return bodyElement.ValueKind == JsonValueKind.String ? bodyElement.GetString() : bodyElement.GetRawText(); } return null; } catch { // best effort — a malformed/unexpected response body degrades to "no data" rather // than throwing back into the caller (see class doc comment). return null; } } } }