Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6cacadf
feat: added ReportDate Query Param, updated XML, added GetExceptionsB…
Warren-Pitterson Aug 19, 2025
05b6bc3
Merge branch 'main' into feat/DTOSS-10556-Exception-Reports
Warren-Pitterson Aug 26, 2025
d649852
test: GetExceptionsByReportDate Tests
Warren-Pitterson Aug 26, 2025
7ca4559
feat: format provider added to helper method
Warren-Pitterson Aug 26, 2025
fdded20
chore: sonarQube warnings and default value
Warren-Pitterson Aug 26, 2025
f606fe1
Merge branch 'main' into feat/DTOSS-10556-Exception-Reports
Warren-Pitterson Aug 26, 2025
941490e
Merge branch 'main' into feat/DTOSS-10556-Exception-Reports
Warren-Pitterson Aug 26, 2025
4836618
Merge branch 'feat/DTOSS-10556-Exception-Reports' of https://github.c…
Warren-Pitterson Aug 26, 2025
ef00900
feat: isReport added, GetReportExceptions logic changed to filter by …
Warren-Pitterson Aug 26, 2025
049f091
fix: default value already applied to helper method, removed
Warren-Pitterson Aug 26, 2025
dbc58f7
test: tests added
Warren-Pitterson Aug 26, 2025
b031502
chore: renamed variable
Warren-Pitterson Aug 26, 2025
cfd3559
Merge branch 'main' into feat/DTOSS-10556-Exception-Reports
Warren-Pitterson Aug 26, 2025
fd40001
feat: abstract logic
Warren-Pitterson Aug 26, 2025
8a6e7f6
chore: XML docs
Warren-Pitterson Aug 26, 2025
fce85a3
chore: ternary operator formatting
Warren-Pitterson Aug 26, 2025
4730102
fix: logic error in GetReportExceptions
Warren-Pitterson Aug 26, 2025
0130f6b
fix: date fix
Warren-Pitterson Aug 27, 2025
f84f83b
fix: category logic fix
Warren-Pitterson Aug 27, 2025
2f891f1
refactor: refactor and tidy up GetReportExceptions method
Warren-Pitterson Aug 27, 2025
ca197f9
test: updated tests
Warren-Pitterson Aug 27, 2025
3a31dff
Merge branch 'main' into feat/DTOSS-10556-Exception-Reports
Warren-Pitterson Sep 1, 2025
3a84d56
fix: improve query performance and null ID handling
Warren-Pitterson Sep 1, 2025
05ba527
chore: removed unused variable
Warren-Pitterson Sep 1, 2025
db1ab0e
tests: update tests
Warren-Pitterson Sep 1, 2025
ae6fad9
chore: tidy null check
Warren-Pitterson Sep 1, 2025
85a0f2c
Merge branch 'main' into feat/DTOSS-10556-Exception-Reports
Warren-Pitterson Sep 1, 2025
bbde069
chore: removed unused variable
Warren-Pitterson Sep 1, 2025
36cd918
feat: exception Details added, Pagination fixed for non reports, Get…
Warren-Pitterson Sep 1, 2025
c74545d
Merge branch 'main' into feat/DTOSS-10556-Exception-Reports
Warren-Pitterson Sep 1, 2025
ad1b482
test: test date
Warren-Pitterson Sep 1, 2025
2026ce3
Merge branch 'feat/DTOSS-10556-Exception-Reports' of https://github.c…
Warren-Pitterson Sep 1, 2025
0634738
fix: renamed BuildNavigationHeaders to AddNavigationHeaders
Warren-Pitterson Sep 1, 2025
8459db2
Merge branch 'main' into feat/DTOSS-10556-Exception-Reports
Warren-Pitterson Sep 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace Common;

using System.Globalization;
using System.Net;
using Common.Interfaces;
using Microsoft.Azure.Functions.Worker.Http;
Expand Down Expand Up @@ -69,4 +70,45 @@ public static T GetEnumQueryParameter<T>(HttpRequestData req, string key, T defa

return Enum.TryParse<T>(queryString, true, out var result) ? result : defaultValue;
}

/// <summary>
/// Parses a DateTime query parameter from the request. Supports various date formats.
/// </summary>
/// <param name="req">The HTTP request data</param>
/// <param name="key">The query parameter key name</param>
/// <returns>The parsed DateTime value or null if parsing fails or parameter is missing</returns>
public DateTime? GetQueryParameterAsDateTime(HttpRequestData req, string key)
{
var queryString = req.Query[key];

if (string.IsNullOrWhiteSpace(queryString))
{
return null;
}

string[] formats = {
"yyyy-MM-dd",
Comment thread
Warren-Pitterson marked this conversation as resolved.
"yyyy/MM/dd",
"dd/MM/yyyy",
"dd-MM-yyyy",
"MM/dd/yyyy",
"MM-dd-yyyy",
"yyyy-MM-ddTHH:mm:ss",
"yyyy-MM-ddTHH:mm:ssZ",
"yyyy-MM-dd HH:mm:ss"
};

if (DateTime.TryParse(queryString, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result))
{
return result;
}

if (DateTime.TryParseExact(queryString, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out result))
{
return result;
}

_logger.LogWarning("Failed to parse date parameter '{Key}' with value '{Value}'", key, queryString);
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ public interface IHttpParserHelper
HttpResponseData LogErrorResponse(HttpRequestData req, string errorMessage);
int GetQueryParameterAsInt(HttpRequestData req, string key, int defaultValue = 0);
bool GetQueryParameterAsBool(HttpRequestData req, string key, bool defaultValue = false);
DateTime? GetQueryParameterAsDateTime(HttpRequestData req, string key);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ namespace Common;
public interface IPaginationService<T>
{
PaginationResult<T> GetPaginatedResult(IQueryable<T> source, int? lastId, Func<T, int>? idSelector = null);
Dictionary<string, string> BuildPaginationHeaders<TEntity>(HttpRequestData request, PaginationResult<TEntity> paginationResult);
Dictionary<string, string> AddNavigationHeaders<TEntity>(HttpRequestData request, PaginationResult<TEntity> paginationResult);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public PaginationResult<T> GetPaginatedResult(IQueryable<T> source, int? lastId,
}

var items = source.Take(pageSize).ToList();
int? lastResultId = items.Count > 0 ? idSelector(items[items.Count - 1]) : null;
int? lastResultId = items.Count > 0 ? idSelector(items[^1]) : null;

return new PaginationResult<T>
{
Expand All @@ -39,11 +39,14 @@ public PaginationResult<T> GetPaginatedResult(IQueryable<T> source, int? lastId,
LastResultId = lastResultId,
TotalItems = totalItems,
TotalPages = totalPages,
CurrentPage = currentPage
CurrentPage = currentPage,
};
}

public Dictionary<string, string> BuildPaginationHeaders<TEntity>(HttpRequestData request, PaginationResult<TEntity> paginationResult)
/// <summary>
/// Adds pagination navigation headers to the response.
/// </summary>
public Dictionary<string, string> AddNavigationHeaders<TEntity>(HttpRequestData request, PaginationResult<TEntity> paginationResult)
{
var headers = new Dictionary<string, string>
{
Expand Down Expand Up @@ -100,15 +103,16 @@ private static string RemoveLastIdParam(string queryString)

private static Func<T, int> GetDefaultIdSelector()
{
var idProperty = typeof(T).GetProperty("Id") ??
typeof(T).GetProperty($"{typeof(T).Name}Id");

if (idProperty == null)
{
var idProperty = (typeof(T).GetProperty("Id") ??
typeof(T).GetProperty($"{typeof(T).Name}Id")) ??
throw new InvalidOperationException(
"Could not find a default ID property. Provide a custom ID selector.");
}

return x => (int)idProperty.GetValue(x);
return x =>
{
var value = idProperty.GetValue(x) ?? throw new InvalidOperationException(
$"Entity of type {typeof(T).Name} has a null ID property ('{idProperty.Name}').");
return (int)value;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ public interface IValidationExceptionData
Task<ValidationException?> GetExceptionById(int exceptionId);
Task<bool> RemoveOldException(string nhsNumber, string screeningName);
Task<ServiceResponseModel> UpdateExceptionServiceNowId(int exceptionId, string serviceNowId);
Task<List<ValidationException>?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory);
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,42 @@ public async Task<ServiceResponseModel> UpdateExceptionServiceNowId(int exceptio
}
}

public async Task<List<ValidationException>?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory)
{
if (exceptionCategory is not (ExceptionCategory.Confusion or ExceptionCategory.Superseded or ExceptionCategory.NBO))
{
return [];
}

var filteredExceptions = (await _validationExceptionDataServiceClient.GetByFilter(x =>
x.Category.HasValue && (x.Category.Value == (int)ExceptionCategory.Confusion || x.Category.Value == (int)ExceptionCategory.Superseded)))?.AsEnumerable();

if (exceptionCategory == ExceptionCategory.Confusion || exceptionCategory == ExceptionCategory.Superseded)
{
filteredExceptions = filteredExceptions?.Where(x => x.Category.HasValue && x.Category.Value == (int)exceptionCategory);
}

if (reportDate.HasValue)
{
var startDate = reportDate.Value.Date;
var endDate = startDate.AddDays(1);
filteredExceptions = filteredExceptions?.Where(x => x.DateCreated >= startDate && x.DateCreated < endDate);
}

if (filteredExceptions?.Any() != true)
return [];

var tasks = filteredExceptions.Select(async exception =>
{
var validationException = exception.ToValidationException();
var participantDemographic = long.TryParse(exception.NhsNumber, out long nhsNumber) ? await _demographicDataServiceClient.GetSingleByFilter(x => x.NhsNumber == nhsNumber) : null;
return GetExceptionDetails(validationException, participantDemographic);
});

var results = await Task.WhenAll(tasks);
return results.Where(x => x != null).ToList()!;
}

private ServiceResponseModel CreateResponse(bool success, HttpStatusCode statusCode, string message)
{
if (!success)
Expand Down Expand Up @@ -187,7 +223,7 @@ private ServiceResponseModel CreateResponse(bool success, HttpStatusCode statusC
{

var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber && x.ScreeningName == screeningName);
return exceptions != null ? exceptions.ToList() : null;
return exceptions?.ToList();

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,9 @@ namespace NHS.CohortManager.ScreeningDataServices;
using Model.Enums;

/// <summary>
/// Azure Function for retrieving cohort distribution data based on ScreeningServiceId.
/// Azure Function for retrieving and managing validation exceptions.
/// Provides endpoints for exception queries, reports, and ServiceNowId updates.
/// </summary>
/// <param name="req">The HTTP request data containing query parameters and request details.</param>
/// <param name="exceptionId">query parameter used to search for an exception by Id..</param>
/// if not exceptionId is passed in the full list of exceptions will be returned
/// <returns>
/// HTTP response with:
/// - 204 No Content if no data is found.
/// - 200 OK - List<GetValidationExceptions> or single GetValidationExcept in JSON format .
/// - 500 Internal Server Error if an exception occurs.
/// </returns>
public class GetValidationExceptions
{
private readonly ILogger<GetValidationExceptions> _logger;
Expand All @@ -32,7 +24,6 @@ public class GetValidationExceptions
private readonly IHttpParserHelper _httpParserHelper;
private readonly IPaginationService<ValidationException> _paginationService;


public GetValidationExceptions(ILogger<GetValidationExceptions> logger, ICreateResponse createResponse, IValidationExceptionData validationData, IHttpParserHelper httpParserHelper, IPaginationService<ValidationException> paginationService)
{
_logger = logger;
Expand All @@ -42,6 +33,15 @@ public GetValidationExceptions(ILogger<GetValidationExceptions> logger, ICreateR
_paginationService = paginationService;
}

/// <summary>
/// Retrieves validation exceptions based on query parameters.
/// Supports single exception lookup, filtered lists, and report-based queries.
/// </summary>
/// <param name="req">The HTTP request data containing query parameters.</param>
/// <returns>
/// HTTP response containing validation exceptions in JSON format.
/// Returns 200 OK with data, 204 No Content if empty, 400 Bad Request for validation errors, or 500 Internal Server Error.
/// </returns>
[Function(nameof(GetValidationExceptions))]
public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req)
{
Expand All @@ -50,33 +50,32 @@ public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymou
var exceptionStatus = HttpParserHelper.GetEnumQueryParameter(req, "exceptionStatus", ExceptionStatus.All);
var sortOrder = HttpParserHelper.GetEnumQueryParameter(req, "sortOrder", SortOrder.Descending);
var exceptionCategory = HttpParserHelper.GetEnumQueryParameter(req, "exceptionCategory", ExceptionCategory.NBO);
var reportDate = _httpParserHelper.GetQueryParameterAsDateTime(req, "reportDate");
var isReport = _httpParserHelper.GetQueryParameterAsBool(req, "isReport");

try
{
if (exceptionId > 0)
{
var exceptionById = await _validationData.GetExceptionById(exceptionId);
return exceptionById == null
? _createResponse.CreateHttpResponse(HttpStatusCode.NoContent, req)
: _createResponse.CreateHttpResponse(HttpStatusCode.OK, req, JsonSerializer.Serialize(exceptionById));
? _createResponse.CreateHttpResponse(HttpStatusCode.NoContent, req)
: _createResponse.CreateHttpResponse(HttpStatusCode.OK, req, JsonSerializer.Serialize(exceptionById));
}

var allExceptions = await _validationData.GetAllFilteredExceptions(exceptionStatus, sortOrder, exceptionCategory);

if (allExceptions == null)
if (isReport)
{
return _createResponse.CreateHttpResponse(HttpStatusCode.NoContent, req);
}
if (reportDate.HasValue && reportDate.Value > DateTime.Now.Date)
{
return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "Report date cannot be in the future.");
}

var paginatedResult = _paginationService.GetPaginatedResult(allExceptions.AsQueryable(), lastId == 0 ? null : lastId, e => e.ExceptionId);
if (!paginatedResult.Items.Any())
{
return _createResponse.CreateHttpResponse(HttpStatusCode.NoContent, req);
var reportExceptions = await _validationData.GetReportExceptions(reportDate, exceptionCategory);
return CreatePaginatedResponse(req, reportExceptions, lastId);
}

var headers = _paginationService.BuildPaginationHeaders(req, paginatedResult);

return _createResponse.CreateHttpResponseWithHeaders(HttpStatusCode.OK, req, JsonSerializer.Serialize(paginatedResult.Items), headers);
var allExceptions = await _validationData.GetAllFilteredExceptions(exceptionStatus, sortOrder, exceptionCategory);
return CreatePaginatedResponse(req, allExceptions, lastId);
}
catch (Exception ex)
{
Expand All @@ -85,16 +84,28 @@ public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymou
}
}


private HttpResponseData CreatePaginatedResponse(HttpRequestData req, List<ValidationException>? exceptions, int lastId)
{
if (exceptions == null || exceptions.Count == 0)
{
return _createResponse.CreateHttpResponse(HttpStatusCode.NoContent, req);
}

var paginatedResult = _paginationService.GetPaginatedResult(exceptions.AsQueryable(), lastId == 0 ? null : lastId, e => e.ExceptionId);

if (!paginatedResult.Items.Any())
{
return _createResponse.CreateHttpResponse(HttpStatusCode.NoContent, req);
}

var headers = _paginationService.AddNavigationHeaders(req, paginatedResult);
return _createResponse.CreateHttpResponseWithHeaders(HttpStatusCode.OK, req, JsonSerializer.Serialize(paginatedResult), headers);
}

/// <summary>
/// Updates the ServiceNow ID for a specific validation exception.
/// Updates the ServiceNowId for a specific validation exception.
/// </summary>
/// <param name="req">The HTTP request data containing the exception ID and ServiceNow ID.</param>
/// <returns>
/// HTTP response with:
/// - 200 OK if the update is successful
/// - 400 Bad Request if required parameters are missing
/// - 500 Internal Server Error if an exception occurs
/// </returns>
[Function(nameof(UpdateExceptionServiceNowId))]
public async Task<HttpResponseData> UpdateExceptionServiceNowId([HttpTrigger(AuthorizationLevel.Anonymous, "put")] HttpRequestData req)
{
Expand Down
Loading
Loading