using System; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Microsoft.Extensions.Logging; using RecruitmentDAL.CustomeCode.CandidateDemographic; using RecruitmentDAL.DTO.CandidateDemographic; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.CandidateDemographic { // See ICandidateDemographicBLL for why there is no GetCandidateDemographic(int) method on // this BLL — the individual-record read path is deliberately omitted for this table. public class CandidateDemographicBLL : ICandidateDemographicBLL { private readonly ICandidateDemographicDAL _CandidateDemographicDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly KeyInvalidate _KeyInvalidate; private readonly BaseEntityAppService _BaseEntityAppService; private readonly ILogger _Logger; public CandidateDemographicBLL( ICandidateDemographicDAL candidateDemographicDAL, AutoNumber autoNumber, IValidation validation, KeyInvalidate keyInvalidate, BaseEntityAppService baseEntityAppService, ILogger logger) { _CandidateDemographicDAL = candidateDemographicDAL; _AutoNumber = autoNumber; _Validation = validation; _KeyInvalidate = keyInvalidate; _BaseEntityAppService = baseEntityAppService; _Logger = logger; } // ── Upsert-by-CandidateId, with the load-bearing consent guard for this whole feature ── // // DESIGN DECISION (per task requirement — documented here, not just at the FLS bridge call // site, so it holds even if a future caller invokes this BLL method directly instead of // going through RecruitmentFlsBridgeBLL's VoluntarySelfId handler): // // When dto.ConsentToCollect is anything other than an explicit `true` on input, this // method does NOT refuse the write outright. Instead it still upserts a row, but forces // ConsentToCollect=false and scrubs every demographic data field (GenderIdentity, // EthnicityRace, DisabilityStatus, VeteranStatus, AgeBand) to null before persisting — // i.e. a clean "asked, declined" record. Two reasons this was chosen over "don't write // anything at all": // 1. Compliance/audit value — affirmative-action-style recordkeeping (e.g. US OFCCP // Section 503/VEVRAA-style self-ID programs) typically wants evidence that the // invitation to self-identify was actually made and that a decline was honored, not // silence indistinguishable from "never asked". // 2. It matches the migration's own header comment: "a candidate can decline without // the system inferring anything from a blank field" — the schema already anticipates // a stored declined-with-nulls row (NULL="never asked" is preserved as a distinct // state from a row that exists with ConsentToCollect=0 and every field null). // Under no circumstances does this method persist a populated demographic field unless // ConsentToCollect=true was explicitly supplied in the same call — that check happens // BEFORE any field is read into the row. public async Task SaveCandidateDemographic(CandidateDemographicDTO CandidateDemographicDTO, LoginDTO LoginDTO, CancellationToken ct) { if (CandidateDemographicDTO == null) throw new ArgumentNullException(nameof(CandidateDemographicDTO)); AutoNumberDTO? autoNumberDTO = null; try { GB5Trace.Step("validate-candidatedemographic", new { CandidateDemographicDTO.CandidateId }); if (CandidateDemographicDTO.CandidateId <= 0) throw new ValidationException("CandidateId is required to save a CandidateDemographic row."); // ── Consent guard — see method doc comment. Runs unconditionally, before any // lookup/insert/update, so it can never be bypassed by a caller that only // partially fills the DTO. ── if (CandidateDemographicDTO.ConsentToCollect != true) { CandidateDemographicDTO.ConsentToCollect = false; CandidateDemographicDTO.GenderIdentity = null; CandidateDemographicDTO.EthnicityRace = null; CandidateDemographicDTO.DisabilityStatus = null; CandidateDemographicDTO.VeteranStatus = null; CandidateDemographicDTO.AgeBand = null; } CandidateDemographicDTO.TenantId = LoginDTO.ClientId; CandidateDemographicDTO.ModifiedById = LoginDTO.UserId; CandidateDemographicDTO.ModifiedOn = DateTime.UtcNow; CandidateDemographicDTO.CollectedOn = DateTime.UtcNow; // ── Upsert-by-CandidateId — this table is one row per Candidate (UNIQUE // constraint), so every save first resolves whether an active row already // exists rather than trusting an incoming CandidateDemographicId. ── var existing = await _CandidateDemographicDAL .GetByCandidateId(CandidateDemographicDTO.CandidateId, LoginDTO, ct) .ConfigureAwait(false); bool isNew = existing == null; if (isNew) { autoNumberDTO = await _AutoNumber.GetNumberAsync( 1, "CANDIDATEDEMOGRAPHIC", LoginDTO).ConfigureAwait(false); CandidateDemographicDTO.CandidateDemographicId = autoNumberDTO.StartNumber; CandidateDemographicDTO.CreatedById = LoginDTO.UserId; CandidateDemographicDTO.CreatedOn = DateTime.UtcNow; } else { CandidateDemographicDTO.CandidateDemographicId = existing!.CandidateDemographicId; CandidateDemographicDTO.CreatedById = existing.CreatedById; CandidateDemographicDTO.CreatedOn = existing.CreatedOn; } GB5Trace.Step("save-candidatedemographic", new { CandidateDemographicDTO.CandidateId, isNew, CandidateDemographicDTO.ConsentToCollect }); await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTRECRUITMENTCANDIDATEDEMOGRAPHIC, isNew ? EventTypeConstant.SAVERECRUITMENTCANDIDATEDEMOGRAPHICEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTCANDIDATEDEMOGRAPHICEVENTTYPEID, CandidateDemographicDTO, LoginDTO, async tx => { _ = isNew ? await _CandidateDemographicDAL.SaveCandidateDemographic(CandidateDemographicDTO, LoginDTO, tx, ct).ConfigureAwait(false) : await _CandidateDemographicDAL.UpdateCandidateDemographic(CandidateDemographicDTO, LoginDTO, tx, ct).ConfigureAwait(false); return CandidateDemographicDTO.CandidateDemographicId; }, null, -1, -1, null, isNew).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = isNew ? EventTypeConstant.SAVERECRUITMENTCANDIDATEDEMOGRAPHICEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTCANDIDATEDEMOGRAPHICEVENTTYPEID }); var cacheKey = new CacheKeyGeneration().KeyGeneration( CandidateDemographicDTO.CandidateDemographicId, EntityConstant.OBJECTRECRUITMENTCANDIDATEDEMOGRAPHIC, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); // Never log any actual demographic field value (GenderIdentity/EthnicityRace/ // DisabilityStatus/VeteranStatus/AgeBand) — only ids and the consent flag. _Logger.LogInformation( "CandidateDemographic {CandidateDemographicId} for Candidate {CandidateId} {Action} by user {UserId} (ConsentToCollect={ConsentToCollect})", CandidateDemographicDTO.CandidateDemographicId, CandidateDemographicDTO.CandidateId, isNew ? "saved" : "updated", LoginDTO.UserId, CandidateDemographicDTO.ConsentToCollect); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {CandidateDemographicDTO.CandidateDemographicId}" : SuccessResponse.UpdateSuccess; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-candidatedemographic-failed", vex); if (autoNumberDTO != null && CandidateDemographicDTO.CandidateDemographicId > 0) await _AutoNumber.RollbackAutoNumber("CANDIDATEDEMOGRAPHIC", CandidateDemographicDTO.CandidateDemographicId, LoginDTO).ConfigureAwait(false); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-candidatedemographic-failed", ex); _Logger.LogError(ex, "SaveCandidateDemographic failed for CandidateId {CandidateId}", CandidateDemographicDTO.CandidateId); if (autoNumberDTO != null && CandidateDemographicDTO.CandidateDemographicId > 0) { await _AutoNumber.RollbackAutoNumber( "CANDIDATEDEMOGRAPHIC", CandidateDemographicDTO.CandidateDemographicId, LoginDTO).ConfigureAwait(false); } throw; } } public async Task DeleteCandidateDemographic(int CandidateDemographicId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("delete-candidatedemographic", new { CandidateDemographicId }); var result = await _CandidateDemographicDAL.DeleteCandidateDemographic(CandidateDemographicId, LoginDTO, ct) .ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( CandidateDemographicId, EntityConstant.OBJECTRECRUITMENTCANDIDATEDEMOGRAPHIC, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation("CandidateDemographic {CandidateDemographicId} deleted by user {UserId}", CandidateDemographicId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-candidatedemographic-failed", ex); _Logger.LogError(ex, "DeleteCandidateDemographic failed for CandidateDemographicId {CandidateDemographicId}", CandidateDemographicId); throw; } } } }