using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using System.Threading.Tasks; using FLSBLL.Registration; using FLSBLL.Respondent; using FLSBLL.Session; using FLSDAL.DTO.Registration; using FLSDAL.DTO.Respondent; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.Telemetry; using Newtonsoft.Json; using RecruitmentBLL.Application; using RecruitmentBLL.BgVerification; using RecruitmentBLL.Candidate; using RecruitmentBLL.CandidateDemographic; using RecruitmentBLL.FlsAction; using RecruitmentBLL.Interview; using RecruitmentBLL.JobOffer; using RecruitmentDAL.CustomeCode.FlsBridge; using RecruitmentDAL.DTO.Application; using RecruitmentDAL.DTO.BgVerification; using RecruitmentDAL.DTO.Candidate; using RecruitmentDAL.DTO.CandidateDemographic; using RecruitmentDAL.DTO.FlsBridge; using RecruitmentDAL.DTO.JobOffer; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.FlsBridge { // Unified FLS bridge adapter for Recruitment. Reference implementation read in full before // writing this: TMSBLL.AssessmentFlsBridge.AssessmentFlsCoordinatorBLL — the // Prepare/Get/Submit shape, the cross-tenant token-lookup pattern (ClientId=0/UserId=-1 for // the FLS-side call, then re-derive the real tenant from the bridge row's own TenantId for // every subsequent Recruitment read/write) and the "coordinator" lazy-provisioning idea are // all carried over directly. // // Deliberate difference from TMS: no separate coordinator table (TASSESSMENTFLSCOORDINATOR's // equivalent) — the task scope for this bridge is ONE new table // (TRECRUITMENTFLSACTION). EnsureCoordinatorAsync instead locates the shared per-tenant // MFLSREGISTRATION/MFLSINSTANCE/MFLSINSTANCEGROUP triple by natural key // (RegistrationCode/InstanceCode/GroupCode = "RECCOORD{ClientId}" family), lazily creating // it on first use exactly like TMS does, just without persisting the resolved ids anywhere // beyond those natural keys. public class RecruitmentFlsBridgeBLL : IRecruitmentFlsBridgeBLL { private readonly IRecruitmentFlsActionDAL _BridgeDAL; private readonly IRecruitmentFlsActionBLL _RecruitmentFlsActionBLL; private readonly IFlsRegistrationBLL _FlsRegistrationBLL; private readonly IFlsRespondentBLL _FlsRespondentBLL; private readonly IFlsSessionBLL _FlsSessionBLL; private readonly IApplicationBLL _ApplicationBLL; private readonly ICandidateBLL _CandidateBLL; private readonly IJobOfferBLL _JobOfferBLL; private readonly IInterviewBLL _InterviewBLL; private readonly ICandidateDemographicBLL _CandidateDemographicBLL; private readonly IBgVerificationBLL _BgVerificationBLL; private readonly AutoNumber _AutoNumber; public RecruitmentFlsBridgeBLL( IRecruitmentFlsActionDAL bridgeDAL, IRecruitmentFlsActionBLL recruitmentFlsActionBLL, IFlsRegistrationBLL flsRegistrationBLL, IFlsRespondentBLL flsRespondentBLL, IFlsSessionBLL flsSessionBLL, IApplicationBLL applicationBLL, ICandidateBLL candidateBLL, IJobOfferBLL jobOfferBLL, IInterviewBLL interviewBLL, ICandidateDemographicBLL candidateDemographicBLL, IBgVerificationBLL bgVerificationBLL, AutoNumber autoNumber) { _BridgeDAL = bridgeDAL; _RecruitmentFlsActionBLL = recruitmentFlsActionBLL; _FlsRegistrationBLL = flsRegistrationBLL; _FlsRespondentBLL = flsRespondentBLL; _FlsSessionBLL = flsSessionBLL; _ApplicationBLL = applicationBLL; _CandidateBLL = candidateBLL; _JobOfferBLL = jobOfferBLL; _InterviewBLL = interviewBLL; _CandidateDemographicBLL = candidateDemographicBLL; _BgVerificationBLL = bgVerificationBLL; _AutoNumber = autoNumber; } // RESPONDENTTYPE: 0=Employee, 1=User, 2=Contact (see FLSDAL/Script/fls.sql) — a // Recruitment candidate/referee is always external, so Contact (2) is the closest fit; // RESPONDENTREFID holds ApplicationId rather than a true MCONTACT FK, the same kind of // documented field-repurposing TMS's own bridge does for RespondentRefId=EnrollmentId. private const int RESPONDENTTYPE_CONTACT = 2; // Lazily provisions one shared coordinator Registration+Instance+Group per tenant on // first use, located by natural key rather than a persisted coordinator row (see class // doc comment for why). Mirrors AssessmentFlsCoordinatorBLL.EnsureCoordinatorAsync's // documented deliberate bypass of FlsInstanceBLL.CreateInstanceAsync/ScheduleInstanceAsync/ // OpenInstanceAsync — this coordinator instance never needs a scheduled OpensFrom/ClosesOn // window, it stays open indefinitely for on-demand dispatch. private async Task EnsureCoordinatorAsync(LoginDTO loginDTO, CancellationToken ct) { string registrationCode = $"RECCOORD{loginDTO.ClientId}"; string instanceCode = $"RECCOORD-{loginDTO.ClientId}"; const string groupCode = "DEFAULT"; int flsRegistrationId = await _BridgeDAL.ResolveRegistrationIdByCode(registrationCode, loginDTO, ct).ConfigureAwait(false); if (flsRegistrationId <= 0) { GB5Trace.Step("provision-recruitment-fls-coordinator", new { loginDTO.ClientId }); // FORMTEMPLATEID for 'RECRUITMENT_FLS_COORDINATOR' — seeded by migration // 20260903_Recruitment_Phase3_FlsBridge_Schema_{SqlServer,Postgres}.sql. int formTemplateId = await _BridgeDAL.ResolveFormTemplateIdByCode("RECRUITMENT_FLS_COORDINATOR", loginDTO, ct).ConfigureAwait(false); if (formTemplateId <= 0) throw new InvalidOperationException("MFORMTEMPLATE seed row 'RECRUITMENT_FLS_COORDINATOR' not found — apply migration 20260903_Recruitment_Phase3_FlsBridge_Schema_SqlServer.sql (or _Postgres.sql) first."); // No confirmed live MMODULE.MODULECODE = 'RECRUITMENT' row was found anywhere in // this repo's migrations as of this task (unlike TMS's confirmed-live 'TRAINING' // row) — best-effort resolve, falling back to the -1 sentinel. MODULEID carries // no FK constraint on MFLSREGISTRATION (confirmed against // 20260627_FLS_Schema.sql), so -1 does not violate anything at the DB level; // flagged here as an assumption to verify once a real MODULECODE is confirmed. int moduleId = await _BridgeDAL.ResolveModuleIdByCode("RECRUITMENT", loginDTO, ct).ConfigureAwait(false); if (moduleId <= 0) { GB5Trace.Step("recruitment-fls-coordinator-moduleid-fallback", new { loginDTO.ClientId }); moduleId = -1; } var regResult = await _FlsRegistrationBLL.CreateRegistrationAsync(new CreateFlsRegistrationRequest( FlsRegistrationId: 0, FormTemplateId: formTemplateId, RegistrationCode: registrationCode, RegistrationName: "Recruitment FLS Action Coordinator", ModuleId: moduleId, CompletionMethod: 4, // ExternalModule — Recruitment's own SL endpoints render/record everything, FLS is purely link/lifecycle AllowDraftResponse: 0, AllowMultiAttempt: 1, IsMandatory: 0, TokenExpiryHours: 168, // 7 days TriggeredBy: 0, DefaultEipTemplCode: null, WebhookPath: null, ReminderRules: new List() ), loginDTO, ct).ConfigureAwait(false); flsRegistrationId = ParseTrailingId(regResult); } int flsInstanceId = await _BridgeDAL.ResolveInstanceIdByCode(instanceCode, flsRegistrationId, loginDTO, ct).ConfigureAwait(false); if (flsInstanceId <= 0) { var instanceAutoNum = await _AutoNumber.GetNumberAsync(1, "FLSINSTANCE", loginDTO).ConfigureAwait(false); flsInstanceId = instanceAutoNum.StartNumber; await _BridgeDAL.InsertFlsInstanceOpen(flsInstanceId, flsRegistrationId, instanceCode, "Recruitment FLS Action Coordinator Instance", loginDTO, ct).ConfigureAwait(false); } int groupId = await _BridgeDAL.ResolveGroupIdByCode(groupCode, flsInstanceId, loginDTO, ct).ConfigureAwait(false); if (groupId <= 0) { var groupAutoNum = await _AutoNumber.GetNumberAsync(1, "FLSINSTANCEGROUP", loginDTO).ConfigureAwait(false); groupId = groupAutoNum.StartNumber; await _BridgeDAL.InsertFlsInstanceGroup(groupId, flsInstanceId, groupCode, "Default Group", loginDTO, ct).ConfigureAwait(false); } return new RecruitmentFlsCoordinatorRef { FlsInstanceId = flsInstanceId, GroupId = groupId }; } public async Task PrepareActionAsync(PrepareRecruitmentFlsActionDTO dto, LoginDTO loginDTO, CancellationToken ct) { if (dto == null) throw new ArgumentNullException(nameof(dto)); if (dto.ActionType < RecruitmentFlsActionType.DetailedApplication || dto.ActionType > RecruitmentFlsActionType.VoluntarySelfId) throw new ValidationException($"ActionType must be 1-6. Received: {dto.ActionType}."); if (dto.ApplicationId <= 0) throw new ValidationException("ApplicationId is required to prepare a Recruitment FLS action."); // ── VoluntarySelfId (6) — "voluntary" enforcement point ───────────────────────── // This is the ONLY place a VoluntarySelfId action link is ever minted. There is no // code path anywhere in this BLL (or in any other case in this method/switch) that // fires a VoluntarySelfId action as a side effect of another action type — every // dispatch is a deliberate, standalone PrepareActionAsync(ActionType=VoluntarySelfId) // call, exactly like every other action type. This must stay true for any future // caller: never bundle this action into, or auto-trigger it alongside, a // DetailedApplication/ReferenceCheck/OfferResponse/ShortlistAck/InterviewSlotConfirm // dispatch — a candidate must be free to never even open this link. if (dto.ActionType == RecruitmentFlsActionType.OfferResponse && dto.JobOfferId <= 0) throw new ValidationException("JobOfferId is required for an OfferResponse action."); if (dto.ActionType == RecruitmentFlsActionType.InterviewSlotConfirm && dto.InterviewId <= 0) throw new ValidationException("InterviewId is required for an InterviewSlotConfirm action."); if (string.IsNullOrWhiteSpace(dto.RecipientEmail)) throw new ValidationException("RecipientEmail is required to dispatch a Recruitment FLS action link."); var coordinator = await EnsureCoordinatorAsync(loginDTO, ct).ConfigureAwait(false); GB5Trace.Step("dispatch-recruitment-fls-action-link", new { dto.ActionType, dto.ApplicationId }); var added = await _FlsRespondentBLL.AddRespondentsAsync(new AddRespondentsRequest( FlsInstanceId: coordinator.FlsInstanceId, GroupId: coordinator.GroupId, StepNo: 1, Respondents: new List { new RespondentRequest( RespondentType: RESPONDENTTYPE_CONTACT, RespondentRefId: dto.ApplicationId, SourceObjectTypeId: EntityConstant.OBJECTRECRUITMENTFLSACTION, SourceObjectId: dto.ApplicationId, SourceBizTypeId: -1, SourceReference: null, RespondentName: dto.RecipientName, RespondentMail: dto.RecipientEmail, RespondentMobile: null) }), loginDTO, ct).ConfigureAwait(false); var newRespondent = added.SingleOrDefault(); if (newRespondent == null) throw new InvalidOperationException("FLS respondent was not created — dispatch link unavailable."); var actionDto = new RecruitmentFlsActionDTO { ActionType = dto.ActionType, ApplicationId = dto.ApplicationId, JobOfferId = dto.JobOfferId, InterviewId = dto.InterviewId, FlsRespondentId = newRespondent.FlsRespondentId, // Phase 6 — only meaningful when ActionType=ReferenceCheck; -1 sentinel for // every other dispatch (see PrepareRecruitmentFlsActionDTO.BgVerificationId). BgVerificationId = dto.BgVerificationId }; await _RecruitmentFlsActionBLL.SaveRecruitmentFlsAction(actionDto, loginDTO, ct).ConfigureAwait(false); return new PrepareRecruitmentFlsActionResultDTO { AccessToken = newRespondent.AccessToken, TokenExpiry = newRespondent.TokenExpiry, RecruitmentFlsActionId = actionDto.RecruitmentFlsActionId }; } public async Task GetActionAsync(string token, CancellationToken ct) { if (!Guid.TryParse(token, out var accessToken)) return JsonConvert.SerializeObject(new { Error = "INVALID_TOKEN" }); // Cross-tenant, pre-authentication lookup — mirrors AssessmentFlsCoordinatorBLL. // GetFlsAttemptAsync's identical FlsRespondentDAL.GetByTokenAsync ClientId=0/UserId=-1 // precedent. var crossTenantLogin = new LoginDTO { ClientId = 0, UserId = -1 }; var respondentJson = await _FlsRespondentBLL.GetByTokenAsync(accessToken, crossTenantLogin, ct).ConfigureAwait(false); var respondent = JsonConvert.DeserializeObject(respondentJson); if (respondent == null || respondent.FlsRespondentId == 0) return JsonConvert.SerializeObject(new { Error = "NOT_FOUND" }); if (respondent.TokenExpiry.HasValue && respondent.TokenExpiry.Value < DateTime.UtcNow) return JsonConvert.SerializeObject(new { Error = "LINK_EXPIRED" }); var action = await _BridgeDAL.GetRecruitmentFlsActionByRespondentId(respondent.FlsRespondentId, crossTenantLogin, ct).ConfigureAwait(false); if (action == null) return JsonConvert.SerializeObject(new { Error = "ACTION_NOT_FOUND" }); // The tenant to actually operate against is the action row's own TenantId, captured // when the link was dispatched — the FLS/respondent lookup itself is cross-tenant, // but every Recruitment read below must target the real tenant. var tenantLogin = new LoginDTO { ClientId = action.TenantId, UserId = -1 }; bool alreadyResponded = action.RespondedOn.HasValue; try { switch (action.ActionType) { case RecruitmentFlsActionType.DetailedApplication: { var application = await GetApplicationDTOAsync(action.ApplicationId, tenantLogin, ct).ConfigureAwait(false); var candidate = application != null ? await GetCandidateDTOAsync(application.CandidateId, tenantLogin, ct).ConfigureAwait(false) : null; return JsonConvert.SerializeObject(new { ActionType = action.ActionType, AlreadyResponded = alreadyResponded, ApplicationId = action.ApplicationId, Application = application, Candidate = candidate, // Static extended-application field schema — no separate form-schema // table exists for this yet; the FE renders these as free-text/select // inputs and posts them back under Answers in the submit payload. FormFields = new[] { "AvailableFromDate", "NoticePeriodDays", "CurrentCTC", "ExpectedCTC", "CurrentEmployer", "LinkedInProfile", "PortfolioUrl", "AdditionalNotes" } }); } case RecruitmentFlsActionType.ReferenceCheck: { var application = await GetApplicationDTOAsync(action.ApplicationId, tenantLogin, ct).ConfigureAwait(false); var candidate = application != null ? await GetCandidateDTOAsync(application.CandidateId, tenantLogin, ct).ConfigureAwait(false) : null; return JsonConvert.SerializeObject(new { ActionType = action.ActionType, AlreadyResponded = alreadyResponded, ApplicationId = action.ApplicationId, Candidate = candidate, QuestionnaireFields = new[] { "RelationshipToCandidate", "YearsKnown", "WouldRehire", "StrengthsSummary", "AreasOfImprovement", "OverallRecommendation" } }); } case RecruitmentFlsActionType.OfferResponse: { var offer = await GetJobOfferDTOAsync(action.JobOfferId, tenantLogin, ct).ConfigureAwait(false); return JsonConvert.SerializeObject(new { ActionType = action.ActionType, AlreadyResponded = alreadyResponded, JobOfferId = action.JobOfferId, JobOffer = offer }); } case RecruitmentFlsActionType.ShortlistAck: { var application = await GetApplicationDTOAsync(action.ApplicationId, tenantLogin, ct).ConfigureAwait(false); return JsonConvert.SerializeObject(new { ActionType = action.ActionType, AlreadyResponded = alreadyResponded, ApplicationId = action.ApplicationId, Application = application }); } case RecruitmentFlsActionType.InterviewSlotConfirm: { var interview = await _InterviewBLL.GetInterview(action.InterviewId, tenantLogin, ct).ConfigureAwait(false); return JsonConvert.SerializeObject(new { ActionType = action.ActionType, AlreadyResponded = alreadyResponded, InterviewId = action.InterviewId, Interview = interview }); } case RecruitmentFlsActionType.VoluntarySelfId: { // Deliberately returns a blank form shape only — this NEVER reads back any // previously stored MCANDIDATEDEMOGRAPHIC row (this bridge has no Get path // into that table at all, by design — see CandidateDemographicDTO's doc // comment). The FE renders this as an optional, freely-declinable form; // the candidate can close the link without ever submitting anything. return JsonConvert.SerializeObject(new { ActionType = action.ActionType, AlreadyResponded = alreadyResponded, ApplicationId = action.ApplicationId, DemographicFields = new[] { "GenderIdentity", "EthnicityRace", "DisabilityStatus", "VeteranStatus", "AgeBand" }, ConsentFieldNote = "ConsentToCollect must be submitted as true for any of the fields above to be recorded. Declining (or submitting nothing) is always safe and stores no demographic values." }); } default: return JsonConvert.SerializeObject(new { Error = "UNKNOWN_ACTION_TYPE" }); } } catch (Exception ex) { GB5Trace.MarkFailed("get-recruitment-fls-action-failed", ex); throw; } } public async Task SubmitActionAsync(string token, string responsePayloadJson, CancellationToken ct) { if (!Guid.TryParse(token, out var accessToken)) return JsonConvert.SerializeObject(new { Error = "INVALID_TOKEN" }); var crossTenantLogin = new LoginDTO { ClientId = 0, UserId = -1 }; var respondentJson = await _FlsRespondentBLL.GetByTokenAsync(accessToken, crossTenantLogin, ct).ConfigureAwait(false); var respondent = JsonConvert.DeserializeObject(respondentJson); if (respondent == null || respondent.FlsRespondentId == 0) return JsonConvert.SerializeObject(new { Error = "NOT_FOUND" }); if (respondent.TokenExpiry.HasValue && respondent.TokenExpiry.Value < DateTime.UtcNow) return JsonConvert.SerializeObject(new { Error = "LINK_EXPIRED" }); var action = await _BridgeDAL.GetRecruitmentFlsActionByRespondentId(respondent.FlsRespondentId, crossTenantLogin, ct).ConfigureAwait(false); if (action == null) return JsonConvert.SerializeObject(new { Error = "ACTION_NOT_FOUND" }); if (action.RespondedOn.HasValue) return JsonConvert.SerializeObject(new { Error = "ALREADY_SUBMITTED" }); var tenantLogin = new LoginDTO { ClientId = action.TenantId, UserId = -1 }; FlsActionResponsePayload payload; try { payload = JsonConvert.DeserializeObject(responsePayloadJson) ?? new FlsActionResponsePayload(); } catch (JsonException) { // Not structured JSON — treat the whole body as free-text remarks rather than // failing the submission outright; every action type tolerates an empty // structured payload (DetailedApplication/InterviewSlotConfirm just record it, // ReferenceCheck/ShortlistAck fall back to their Outcome default below). payload = new FlsActionResponsePayload { Remarks = responsePayloadJson }; } try { GB5Trace.Step("submit-recruitment-fls-action", new { action.RecruitmentFlsActionId, action.ActionType }); int newStageHistoryId = -1; switch (action.ActionType) { case RecruitmentFlsActionType.OfferResponse: { byte offerResponseStatus = payload.Decision?.Trim().ToUpperInvariant() switch { "ACCEPT" => (byte)1, "DECLINE" => (byte)2, _ => throw new ValidationException("Decision must be 'Accept' or 'Decline' for an OfferResponse action.") }; await _JobOfferBLL.RecordOfferResponseAsync(action.JobOfferId, offerResponseStatus, tenantLogin, ct).ConfigureAwait(false); break; } case RecruitmentFlsActionType.ReferenceCheck: case RecruitmentFlsActionType.ShortlistAck: { var application = await GetApplicationDTOAsync(action.ApplicationId, tenantLogin, ct).ConfigureAwait(false); // ShortlistAck: acknowledging is itself a pass (default Outcome=2/Passed). // ReferenceCheck: no safe default — a referee must supply an explicit // outcome, else this defaults to Pending(1) rather than silently passing. byte defaultOutcome = action.ActionType == RecruitmentFlsActionType.ShortlistAck ? (byte)2 : (byte)1; var stageHistoryDto = new ApplicationStageHistoryDTO { ApplicationId = action.ApplicationId, SelectionProcessStageId = application?.CurrentStageId ?? -1, Outcome = payload.Outcome ?? defaultOutcome, EvaluatorId = -1, // external candidate/referee, no internal evaluator Recommendation = payload.Recommendation ?? string.Empty, Remarks = payload.Remarks ?? responsePayloadJson }; var stageResult = await _ApplicationBLL.SaveApplicationStageHistory(stageHistoryDto, tenantLogin, ct).ConfigureAwait(false); newStageHistoryId = ParseTrailingId(stageResult); // Phase 6 — additive: when this ReferenceCheck dispatch was opened as // part of BG verification (BgVerificationId set at PrepareActionAsync // time), also update that TBGVERIFICATION row's CheckStatus. Every other // ReferenceCheck/ShortlistAck submission (BgVerificationId still at its // -1 sentinel) is completely unaffected — same TAPPLICATIONSTAGEHISTORY // write as before, nothing else added. if (action.ActionType == RecruitmentFlsActionType.ReferenceCheck && action.BgVerificationId > 0) { try { await _BgVerificationBLL.UpdateCheckStatusAsync(new UpdateBgVerificationStatusDTO { BgVerificationId = action.BgVerificationId, CheckStatus = MapStageOutcomeToBgCheckStatus(stageHistoryDto.Outcome) }, tenantLogin, ct).ConfigureAwait(false); } catch (Exception bgEx) { // Non-fatal — the referee's questionnaire is already recorded in // TAPPLICATIONSTAGEHISTORY above; a failure to also mirror it onto // TBGVERIFICATION should never fail the referee's submission. GB5Trace.MarkFailed("bg-verification-status-update-from-referencecheck-failed", bgEx); } } break; } case RecruitmentFlsActionType.InterviewSlotConfirm: case RecruitmentFlsActionType.DetailedApplication: // No other entity update for these two — the payload is captured on this // row only (DetailedApplication is supplementary data collection, not a // state transition; InterviewSlotConfirm's auto-reschedule is a separate, // not-yet-built feature per this task's scope). break; case RecruitmentFlsActionType.VoluntarySelfId: { // Resolve CandidateId via the Application this action was dispatched // against — MCANDIDATEDEMOGRAPHIC keys off CandidateId, not ApplicationId. var application = await GetApplicationDTOAsync(action.ApplicationId, tenantLogin, ct).ConfigureAwait(false); if (application == null || application.CandidateId <= 0) throw new ValidationException( $"Application {action.ApplicationId} could not be resolved to a Candidate — cannot record a VoluntarySelfId submission."); var demographicDto = new CandidateDemographicDTO { CandidateId = application.CandidateId, // Passed straight through from the submitted payload, unmodified — // CandidateDemographicBLL.SaveCandidateDemographic is the single // authoritative place that enforces the "no populated field without // explicit ConsentToCollect=true" rule (see that method's doc // comment). This bridge deliberately does NOT pre-filter these fields // itself, so there is exactly one enforcement point to audit, not two // that could drift out of sync. ConsentToCollect = payload.ConsentToCollect ?? false, GenderIdentity = payload.GenderIdentity, EthnicityRace = payload.EthnicityRace, DisabilityStatus = payload.DisabilityStatus, VeteranStatus = payload.VeteranStatus, AgeBand = payload.AgeBand }; await _CandidateDemographicBLL.SaveCandidateDemographic(demographicDto, tenantLogin, ct).ConfigureAwait(false); break; } } var actionForUpdate = await GetActionDtoForUpdateAsync(action.RecruitmentFlsActionId, tenantLogin, ct).ConfigureAwait(false) ?? action; actionForUpdate.ResponsePayload = responsePayloadJson; actionForUpdate.RespondedOn = DateTime.UtcNow; if (newStageHistoryId > 0) actionForUpdate.ApplicationStageHistoryId = newStageHistoryId; await _RecruitmentFlsActionBLL.SaveRecruitmentFlsAction(actionForUpdate, tenantLogin, ct).ConfigureAwait(false); // Non-fatal: closing out the FLS side (stop nudges) is a courtesy, not something // that should fail the submission if FLS itself has a problem — same pattern as // AssessmentFlsCoordinatorBLL.SubmitFlsAttemptAsync. try { await _FlsSessionBLL.MarkExternalSubmitAsync( respondent.FlsRespondentId, $"recruitment-action:{actionForUpdate.RecruitmentFlsActionId}", crossTenantLogin, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("fls-mark-external-submit-failed", ex); } return JsonConvert.SerializeObject(new { Success = true, ActionType = actionForUpdate.ActionType, RecruitmentFlsActionId = actionForUpdate.RecruitmentFlsActionId }); } catch (ValidationException vex) { GB5Trace.MarkFailed("submit-recruitment-fls-action-failed", vex); return JsonConvert.SerializeObject(new { Error = "VALIDATION_ERROR", Message = vex.Message }); } catch (Exception ex) { GB5Trace.MarkFailed("submit-recruitment-fls-action-failed", ex); throw; } } // ── Helpers ────────────────────────────────────────────────────────────── // RecruitmentFlsActionBLL.SaveRecruitmentFlsAction always routes through // ExecuteSaveAsync (audit/event-publish/outbox) — re-reading the row via the entity's // own Get before mutating it (rather than mutating the already-in-hand `action` from the // cross-tenant respondent lookup) guards against acting on a stale copy if anything else // touched this row between the token lookup and the submit completing. private async Task GetActionDtoForUpdateAsync(int recruitmentFlsActionId, LoginDTO tenantLogin, CancellationToken ct) { var json = await _RecruitmentFlsActionBLL.GetRecruitmentFlsAction(recruitmentFlsActionId, tenantLogin, ct).ConfigureAwait(false); return string.IsNullOrWhiteSpace(json) ? null : JsonConvert.DeserializeObject(json); } private async Task GetApplicationDTOAsync(int applicationId, LoginDTO tenantLogin, CancellationToken ct) { if (applicationId <= 0) return null; var json = await _ApplicationBLL.GetApplication(applicationId, tenantLogin, ct).ConfigureAwait(false); return string.IsNullOrWhiteSpace(json) ? null : JsonConvert.DeserializeObject(json); } private async Task GetCandidateDTOAsync(int candidateId, LoginDTO tenantLogin, CancellationToken ct) { if (candidateId <= 0) return null; var json = await _CandidateBLL.GetCandidate(candidateId, tenantLogin, ct).ConfigureAwait(false); return string.IsNullOrWhiteSpace(json) ? null : JsonConvert.DeserializeObject(json); } private async Task GetJobOfferDTOAsync(int jobOfferId, LoginDTO tenantLogin, CancellationToken ct) { if (jobOfferId <= 0) return null; var json = await _JobOfferBLL.GetJobOffer(jobOfferId, tenantLogin, ct).ConfigureAwait(false); return string.IsNullOrWhiteSpace(json) ? null : JsonConvert.DeserializeObject(json); } // Parses the trailing "... {id}" convention used by every SuccessResponse. // Save/UpdateSuccessMessage-shaped result string in this repo (e.g. // "Details have been saved successfully with id = -1392500123") — same parsing approach // AssessmentFlsCoordinatorBLL uses for CreateRegistrationAsync's result. private static int ParseTrailingId(string message) { var token = message?.Split(' ').LastOrDefault(); return int.TryParse(token, out var id) ? id : -1; } // Phase 6 — maps a submitted TAPPLICATIONSTAGEHISTORY.Outcome (1=Pending 2=Passed // 3=Failed 4=Skipped 5=Delayed 6=DroppedOff) onto the corresponding // BgVerificationCheckStatus (1=Pending 2=InProgress 3=Passed 4=Failed 5=Delayed) when a // ReferenceCheck submission is fulfilling a TBGVERIFICATION row. Skipped/DroppedOff have // no direct BG-check-status analogue — both close the check out without a genuine // pass, so they map to Failed rather than silently leaving the check open. private static byte MapStageOutcomeToBgCheckStatus(byte outcome) => outcome switch { 2 => BgVerificationCheckStatus.Passed, 3 => BgVerificationCheckStatus.Failed, 5 => BgVerificationCheckStatus.Delayed, 4 => BgVerificationCheckStatus.Failed, 6 => BgVerificationCheckStatus.Failed, _ => BgVerificationCheckStatus.Pending }; } }