using System.Runtime.CompilerServices; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.QueryExecutor; using GB5Shared.Telemetry; using RecruitmentDAL.CustomeCode.Reports.DeiSourcingReport; using RecruitmentDAL.DTO.Reports; namespace RecruitmentBLL.Reports.DeiSourcingReport { // Phase 5 analytics — aggregated-only DEI/diversity sourcing report. CriteriaDTO is accepted // for API-shape consistency with the other Reports-family endpoints in this module (and future // date-range filters) but is not currently used to narrow this report, same as // CandidateSourceQualityBLL's own documented reason (bounded row-count aggregate). // // ── MANDATORY CELL-SUPPRESSION — the load-bearing privacy safeguard for this entire report ── // The underlying SQL (DeiSourcingReportQB) already restricts to CONSENTTOCOLLECT = 1 rows — // that is necessary but NOT sufficient: a breakdown group can still be small enough that its // exact headcount, or a narrow pipeline-outcome count within it (e.g. "1 of this group was // Hired"), effectively re-identifies an individual candidate. This class is the single, // enforced place that rule is applied, in application code, post-query, before ANY row leaves // this BLL — never left as a TODO, never partial, and never bypassable by calling the DAL // directly (see IDeiSourcingReportDAL's doc comment). // // Rule (threshold = 5, applied by ApplyCellSuppression below): // 1. Whole-row omission: any (DemographicDimension, DemographicValue, Source) group whose // CandidateCount is below 5 is dropped from the result entirely — it never reaches the // caller, in either the paged or the streamed path. A demographic breakdown group this // small is itself identifying, independent of any pipeline-outcome count within it. // 2. Per-cell placeholder: for every group that DOES clear the CandidateCount>=5 bar, each // of AppliedCount/InterviewedCount/HiredCount is independently re-checked — any of those // narrower funnel counts that falls in 1-4 is replaced with the literal placeholder // "<5" (never the real number), because a small funnel-stage count can still identify an // individual even inside a large-enough overall group (e.g. "8 candidates disclosed X; // exactly 1 was Hired" still singles that person out). A count of exactly 0 is left as // "0" — zero never identifies anyone. // This is intentionally a superset of "never show an exact count below 5": both the group and // every individual count column are covered, not just one or the other. public class DeiSourcingReportBLL : IDeiSourcingReportBLL { private const int DEFAULT_MAX_RESULT_CAP = 500; private const int SUPPRESSION_THRESHOLD = 5; private readonly IDeiSourcingReportDAL _dal; public DeiSourcingReportBLL(IDeiSourcingReportDAL dal) { _dal = dal; } public async Task> GetDeiSourcingReport( int firstNumber, int maxResult, CriteriaDTO? criteriaDTO, LoginDTO login, CancellationToken ct) { try { var cappedMaxResult = maxResult > 0 ? Math.Min(maxResult, DEFAULT_MAX_RESULT_CAP) : DEFAULT_MAX_RESULT_CAP; GB5Trace.Step("query-dei-sourcing-report", new { firstNumber, cappedMaxResult }); var raw = await _dal.GetDeiSourcingReportRaw(firstNumber, cappedMaxResult, login, ct).ConfigureAwait(false); var suppressed = (raw.Items ?? Enumerable.Empty()) .Select(ApplyCellSuppression) .Where(row => row != null) .Select(row => row!) .ToList(); GB5Trace.Step("dei-sourcing-report-suppression-applied", new { RawGroupCount = raw.Items?.Count() ?? 0, VisibleGroupCount = suppressed.Count }); return new PagedResult { Items = suppressed, // TotalCount reflects only what is actually visible after suppression — the // count of suppressed-away groups is itself not surfaced, so a caller can // never back into "there were N groups too small to show". TotalCount = suppressed.Count }; } catch (Exception ex) { GB5Trace.MarkFailed("dei-sourcing-report-failed", ex); throw; } } public async IAsyncEnumerable GetDeiSourcingReportStream( int firstNumber, int maxResult, CriteriaDTO? criteriaDTO, LoginDTO login, [EnumeratorCancellation] CancellationToken ct) { GB5Trace.Step("stream-dei-sourcing-report", new { firstNumber, maxResult }); await foreach (var raw in _dal.GetDeiSourcingReportRawStream(login, ct).ConfigureAwait(false)) { var row = ApplyCellSuppression(raw); // Suppressed groups are skipped in the stream too — exports and NDJSON must never // see a row this report's paged path would have omitted. if (row != null) yield return row; } } // ── The one enforcement point — see class doc comment for the full rule. ── private static DeiSourcingReportDTO? ApplyCellSuppression(DeiSourcingReportRawDTO raw) { if (raw.CandidateCount < SUPPRESSION_THRESHOLD) return null; return new DeiSourcingReportDTO { DemographicDimension = raw.DemographicDimension, DemographicValue = raw.DemographicValue, Source = raw.Source, SourceName = raw.SourceName, CandidateCount = FormatSuppressedCount(raw.CandidateCount), AppliedCount = FormatSuppressedCount(raw.AppliedCount), InterviewedCount = FormatSuppressedCount(raw.InterviewedCount), HiredCount = FormatSuppressedCount(raw.HiredCount) }; } private static string FormatSuppressedCount(int count) => count > 0 && count < SUPPRESSION_THRESHOLD ? "<5" : count.ToString(); } }