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;
}
}
}
}