diff --git a/DataImport.API/Controllers/CountryController.cs b/DataImport.API/Controllers/CountryController.cs index 20a2a65..9a30ad9 100644 --- a/DataImport.API/Controllers/CountryController.cs +++ b/DataImport.API/Controllers/CountryController.cs @@ -1,9 +1,11 @@ using DataImport.API.Controllers.BaseController; using DataImport.Commands.Queries; -using DataImport.Data.Enums; // <-- add this +using DataImport.Data.Enums; +using DataImport.API.Filters; namespace DataImport.API.Controllers; +[ValidateEnums] [Route("api/v{version:apiVersion}/[controller]")] public class CountryController : ApiControllerBasev1 { @@ -14,33 +16,51 @@ public CountryController(IMediator mediator) _mediator = mediator; } - [HttpPost] - [Route("get-count-by-country")] + [HttpGet("get-count-by-country")] public async Task GetCountryCount( [FromQuery] Country country, CancellationToken ct) { - if (!Enum.IsDefined(typeof(Country), country)) - return BadRequest("Valid country is required"); - var result = await _mediator.Send(new GetCountryCountQuery(country), ct); return Ok(result); } - [HttpPost] - [Route("get-sanctions-by-country")] + [HttpGet("get-delisted-count-by-country")] + public async Task GetDelistedCountByCountry( + [FromQuery] Country country, + CancellationToken ct) + { + var result = await _mediator.Send(new GetCountryDelistedCountQuery(country), ct); + return Ok(result); + } + + [HttpGet("get-sanctions-by-country")] public async Task GetCountrySanctionsPaged( [FromQuery] Country country, [FromQuery] int pageSize = 20, [FromQuery] int page = 1, CancellationToken ct = default) { - if (!Enum.IsDefined(typeof(Country), country)) - return BadRequest("Valid country is required"); + pageSize = Math.Clamp(pageSize, 1, 100); + page = Math.Max(page, 1); + + var result = await _mediator.Send( + new GetCountrySanctionsPagedQuery(country, pageSize, page), ct); + return Ok(result); + } + + [HttpGet("get-delisted-sanctions-by-country")] + public async Task GetDelistedCountrySanctionsPaged( + [FromQuery] Country country, + [FromQuery] int pageSize = 20, + [FromQuery] int page = 1, + CancellationToken ct = default) + { + pageSize = Math.Clamp(pageSize, 1, 100); + page = Math.Max(page, 1); var result = await _mediator.Send( - new GetCountrySanctionsPagedQuery(country, pageSize, page), - ct); + new GetDelistedCountrySanctionsPagedQuery(country, pageSize, page), ct); return Ok(result); } diff --git a/DataImport.API/Controllers/SanctionsController.cs b/DataImport.API/Controllers/SanctionsController.cs index 3ce28e9..3573e9e 100644 --- a/DataImport.API/Controllers/SanctionsController.cs +++ b/DataImport.API/Controllers/SanctionsController.cs @@ -33,5 +33,20 @@ public async Task GetPaged( new GetSanctionsPagedQuery(page, pageSize, sdnType, lastNameContains), ct); return Ok(result); } + + [HttpGet("Paged-delisted-Sanctions")] + public async Task GetDelistedPaged( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? sdnType = null, + [FromQuery] string? lastNameContains = null, + CancellationToken ct = default) + { + var result = await _mediator.Send( + new GetDelistedSanctionsPagedQuery(page, pageSize, sdnType, lastNameContains), ct); + return Ok(result); + } + + } } diff --git a/DataImport.API/Controllers/StatsController.cs b/DataImport.API/Controllers/StatsController.cs new file mode 100644 index 0000000..b81b728 --- /dev/null +++ b/DataImport.API/Controllers/StatsController.cs @@ -0,0 +1,37 @@ +using DataImport.API.Controllers.BaseController; +using DataImport.Commands.Queries; +using DataImport.Presentation.GenericDTO; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace DataImport.API.Controllers; + +[Route("api/v{version:apiVersion}/Stats")] +public class StatsController : ApiControllerBasev1 +{ + private readonly IMediator _mediator; + + public StatsController(IMediator mediator) + { + _mediator = mediator; + } + + [HttpGet("top-countries")] + [ProducesResponseType(typeof(TopCountriesDto), StatusCodes.Status200OK)] + public async Task TopCountries( + [FromQuery] int top = 6, + CancellationToken ct = default) + { + var query = new GetTopCountriesQuery(Math.Clamp(top, 1, 20)); + var result = await _mediator.Send(query, ct); + return Ok(result); + } + + [HttpGet("by-sdn-type")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task BySdnType(CancellationToken ct) + { + var result = await _mediator.Send(new GetSdnTypeCountsQuery(), ct); + return Ok(result); + } +} \ No newline at end of file diff --git a/DataImport.API/Filters/ValidateEnumsAttribute.cs b/DataImport.API/Filters/ValidateEnumsAttribute.cs new file mode 100644 index 0000000..b122dff --- /dev/null +++ b/DataImport.API/Filters/ValidateEnumsAttribute.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace DataImport.API.Filters; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class ValidateEnumsAttribute : ActionFilterAttribute +{ + public override void OnActionExecuting(ActionExecutingContext context) + { + if (!context.ModelState.IsValid) + { + context.Result = new BadRequestObjectResult( + new ValidationProblemDetails(context.ModelState)); + return; + } + + foreach (var (name, value) in context.ActionArguments) + { + if (value is null) continue; + + var type = value.GetType(); + if (type.IsEnum && !Enum.IsDefined(type, value)) + { + context.ModelState.AddModelError(name, $"'{value}' is not a valid {type.Name}."); + context.Result = new BadRequestObjectResult( + new ValidationProblemDetails(context.ModelState)); + return; + } + } + } +} \ No newline at end of file diff --git a/DataImport.Commands/Commands/GetCountryCountQueryCommand.cs b/DataImport.Commands/Commands/GetCountryCountQueryCommand.cs index 477c29b..5d04791 100644 --- a/DataImport.Commands/Commands/GetCountryCountQueryCommand.cs +++ b/DataImport.Commands/Commands/GetCountryCountQueryCommand.cs @@ -6,7 +6,7 @@ namespace DataImport.Commands.Commands; -public record CountryCountResult(bool Success, String Country, int Count); +public record CountryCountResult(bool Success, String Country, int Count, bool isDelisted = false); public class GetCountryCountQueryCommand : IRequestHandler { diff --git a/DataImport.Commands/Commands/GetCountryDelistedCountQueryCommand.cs b/DataImport.Commands/Commands/GetCountryDelistedCountQueryCommand.cs new file mode 100644 index 0000000..7139ab7 --- /dev/null +++ b/DataImport.Commands/Commands/GetCountryDelistedCountQueryCommand.cs @@ -0,0 +1,42 @@ +using DataImport.Commands.Queries; +using DataImport.Data.Data; +using MediatR; +using Microsoft.EntityFrameworkCore; +using ZiggyCreatures.Caching.Fusion; + +namespace DataImport.Commands.Commands; + +public class GetCountryDelistedCountQueryCommand + : IRequestHandler +{ + private readonly SanctionsDbContext _db; + private readonly IFusionCache _cache; + + public GetCountryDelistedCountQueryCommand(SanctionsDbContext db, IFusionCache cache) + { + _db = db; + _cache = cache; + } + + public async Task Handle( + GetCountryDelistedCountQuery request, + CancellationToken ct) + { + var country = request.country.ToString(); + var cacheKey = $"CountryDelistedCount_{country}"; + + return await _cache.GetOrSetAsync( + cacheKey, + async _ => + { + var count = await _db.SanctionDetails + .Where(x => x.Country == country && !x.IsActive) + .CountAsync(ct); + + return new CountryCountResult(true, country, count, isDelisted: true); + }, + options => options.SetDuration(TimeSpan.FromMinutes(5)), + ct + ); + } +} \ No newline at end of file diff --git a/DataImport.Commands/Commands/GetDelistedCountrySanctionsPagedQueryCommand.cs b/DataImport.Commands/Commands/GetDelistedCountrySanctionsPagedQueryCommand.cs new file mode 100644 index 0000000..2a77a06 --- /dev/null +++ b/DataImport.Commands/Commands/GetDelistedCountrySanctionsPagedQueryCommand.cs @@ -0,0 +1,60 @@ +using DataImport.Data.Data; +using DataImport.Presentation.GenericDTO; +using MediatR; +using Microsoft.EntityFrameworkCore; +using ZiggyCreatures.Caching.Fusion; +using Facet.Extensions; +using DataImport.Commands.Queries; + +namespace DataImport.Commands.Commands; + +public class GetDelistedCountrySanctionsPagedQueryHandler + : IRequestHandler> +{ + private readonly SanctionsDbContext _db; + private readonly IFusionCache _cache; + + public GetDelistedCountrySanctionsPagedQueryHandler(SanctionsDbContext db, IFusionCache cache) + { + _db = db; + _cache = cache; + } + + public async Task> Handle( + GetDelistedCountrySanctionsPagedQuery request, CancellationToken ct) + { + var countryName = request.country.ToString(); + + // "Delisted" prefix keeps this from colliding with the active-sanctions cache key + var cacheKey = $"DelistedCountrySanctionsPaged_{countryName}_{request.Page}_{request.PageSize}"; + + return await _cache.GetOrSetAsync>( + cacheKey, + async _ => + { + var query = _db.SanctionDetails + .AsNoTracking() + .Where(x => x.Country == countryName && !x.IsActive); // adjust to your delist field + + var total = await query.CountAsync(ct); + + var items = await query + .OrderBy(x => x.Id) + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .Select(x => x.ToFacet()) + .ToListAsync(ct); + + return new PagedResult( + items, + request.Page, + request.PageSize, + total, + (int)Math.Ceiling(total / (double)request.PageSize) + ); + }, + options => options.SetDuration(TimeSpan.FromMinutes(5)), + ct + ); + } +} \ No newline at end of file diff --git a/DataImport.Commands/Commands/GetDelistedSanctionsPagedQueryCommand.cs b/DataImport.Commands/Commands/GetDelistedSanctionsPagedQueryCommand.cs new file mode 100644 index 0000000..f18a13e --- /dev/null +++ b/DataImport.Commands/Commands/GetDelistedSanctionsPagedQueryCommand.cs @@ -0,0 +1,64 @@ +using DataImport.Data.Data; +using DataImport.Presentation.GenericDTO; +using Facet.Extensions; +using MediatR; +using Microsoft.EntityFrameworkCore; +using DataImport.Commands.Queries; +using ZiggyCreatures.Caching.Fusion; + +namespace DataImport.Commands.Commands; + +public class GetDelistedSanctionsPagedQueryCommand + : IRequestHandler> +{ + private readonly SanctionsDbContext _db; + private readonly IFusionCache _cache; + + + public GetDelistedSanctionsPagedQueryCommand(SanctionsDbContext db, IFusionCache cache) + { + _db = db; + _cache = cache; + } + + public async Task> Handle( + GetDelistedSanctionsPagedQuery request, CancellationToken ct) + { + var cacheKey = + $"DelistedSanctionsPaged_{request.SdnType}_{request.LastNameContains}_{request.Page}_{request.PageSize}"; + + return await _cache.GetOrSetAsync>( + cacheKey, + async _ => + { + var query = _db.SanctionDetails + .AsNoTracking() + .Where(s => !s.IsActive); // apply once, before count + + if (!string.IsNullOrWhiteSpace(request.SdnType)) + query = query.Where(s => s.SdnType == request.SdnType); + + if (!string.IsNullOrWhiteSpace(request.LastNameContains)) + query = query.Where(s => s.LastName.Contains(request.LastNameContains)); + + var total = await query.CountAsync(ct); + + var items = await query + .OrderBy(s => s.LastName) + .ThenBy(s => s.Id) // tiebreaker, see below + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .Select(s => s.ToFacet()) + .ToListAsync(ct); + + return new PagedResult( + items, + request.Page, + request.PageSize, + total, + (int)Math.Ceiling(total / (double)request.PageSize)); + }, + options => options.SetDuration(TimeSpan.FromMinutes(5)), + ct); + } +} \ No newline at end of file diff --git a/DataImport.Commands/Commands/GetSdnTypeCountsQueryHandler.cs b/DataImport.Commands/Commands/GetSdnTypeCountsQueryHandler.cs new file mode 100644 index 0000000..7093fe5 --- /dev/null +++ b/DataImport.Commands/Commands/GetSdnTypeCountsQueryHandler.cs @@ -0,0 +1,48 @@ +using DataImport.Commands.Queries; +using DataImport.Data.Data; +using DataImport.Presentation.GenericDTO; +using MediatR; +using Microsoft.EntityFrameworkCore; +using ZiggyCreatures.Caching.Fusion; + +namespace DataImport.Commands.Commands; + +public class GetSdnTypeCountsQueryHandler + : IRequestHandler> +{ + private readonly SanctionsDbContext _db; + private readonly IFusionCache _cache; + + public GetSdnTypeCountsQueryHandler(SanctionsDbContext db, IFusionCache cache) + { + _db = db; + _cache = cache; + } + + public async Task> Handle( + GetSdnTypeCountsQuery request, + CancellationToken ct) + { + return await _cache.GetOrSetAsync>( + "Stats_SdnTypeCounts", + async _ => + { + // Only consider active rows. + var baseQuery = _db.SanctionDetails + .AsNoTracking() + .Where(s => s.IsActive); + + // Order on the group (translates cleanly to SQL), + // then project into the DTO as the last step. + return await baseQuery + .GroupBy(s => s.SdnType) + .OrderByDescending(g => g.Count()) + .ThenBy(g => g.Key) + .Select(g => new SdnTypeCountDto(g.Key, g.Count())) + .ToListAsync(ct); + }, + options => options.SetDuration(TimeSpan.FromMinutes(5)), + ct + ); + } +} \ No newline at end of file diff --git a/DataImport.Commands/Commands/GetTopCountriesQueryHandler.cs b/DataImport.Commands/Commands/GetTopCountriesQueryHandler.cs new file mode 100644 index 0000000..4bb21eb --- /dev/null +++ b/DataImport.Commands/Commands/GetTopCountriesQueryHandler.cs @@ -0,0 +1,71 @@ +using DataImport.Commands.Queries; +using DataImport.Data.Data; +using DataImport.Presentation.GenericDTO; +using MediatR; +using Microsoft.EntityFrameworkCore; +using ZiggyCreatures.Caching.Fusion; + +namespace DataImport.Commands.Commands; + +public class GetTopCountriesQueryHandler + : IRequestHandler +{ + private readonly SanctionsDbContext _db; + private readonly IFusionCache _cache; + + public GetTopCountriesQueryHandler(SanctionsDbContext db, IFusionCache cache) + { + _db = db; + _cache = cache; + } + + public async Task Handle( + GetTopCountriesQuery request, + CancellationToken ct) + { + var top = request.Top <= 0 ? 6 : request.Top; + + return await _cache.GetOrSetAsync( + $"Stats_TopCountries_{top}", + async _ => + { + // Only consider active rows + var baseQuery = _db.SanctionDetails + .AsNoTracking() + .Where(s => s.IsActive); + + // 1) Total active rows (used for the "Total" figure). + var total = await baseQuery.CountAsync(ct); + + // 2) Unknown = rows with null/empty country. + var unknown = await baseQuery + .Where(s => s.Country == null || s.Country == "") + .CountAsync(ct); + + // 3) Per-country counts for all known countries. + // Order on the group (always translatable to SQL), + // then project into the DTO as the last step. + var byCountry = await baseQuery + .Where(s => s.Country != null && s.Country != "") + .GroupBy(s => s.Country!) + .OrderByDescending(g => g.Count()) + .ThenBy(g => g.Key) + .Select(g => new CountryCountDto(g.Key, g.Count())) + .ToListAsync(ct); + + // 4) Split into top N and "other". + var topN = byCountry.Take(top).ToList(); + var other = byCountry.Skip(top).Sum(x => x.Count); + + return new TopCountriesDto( + Total: total, + Unknown: unknown, + Other: other, + Top: topN + ); + }, + options => options.SetDuration(TimeSpan.FromMinutes(5)), + ct + ); + } +} \ No newline at end of file diff --git a/DataImport.Commands/Queries/CountryQueries.cs b/DataImport.Commands/Queries/CountryQueries.cs index 8adedb0..3889daa 100644 --- a/DataImport.Commands/Queries/CountryQueries.cs +++ b/DataImport.Commands/Queries/CountryQueries.cs @@ -14,5 +14,14 @@ public record GetCountrySanctionsPagedQuery( int Page = 1 ) : IRequest>; + +public record GetDelistedCountrySanctionsPagedQuery(Country country, + int PageSize, + int Page) + : IRequest>; + + public record GetCountryCountQuery(Country country) : IRequest; +public record GetCountryDelistedCountQuery(Country country) : IRequest; + diff --git a/DataImport.Commands/Queries/SanctionQueries.cs b/DataImport.Commands/Queries/SanctionQueries.cs index f82948c..9192dfa 100644 --- a/DataImport.Commands/Queries/SanctionQueries.cs +++ b/DataImport.Commands/Queries/SanctionQueries.cs @@ -13,6 +13,14 @@ public record GetSanctionsPagedQuery( string? LastNameContains = null ) : IRequest>; +public record GetDelistedSanctionsPagedQuery( + int Page = 1, + int PageSize = 20, + string? SdnType = null, + string? LastNameContains = null +) : IRequest>; + + public record SearchRequest(string Name); // Added a normal Tolerance as it is not optimized yet. diff --git a/DataImport.Commands/Queries/StatsQueries.cs b/DataImport.Commands/Queries/StatsQueries.cs new file mode 100644 index 0000000..0e7c72b --- /dev/null +++ b/DataImport.Commands/Queries/StatsQueries.cs @@ -0,0 +1,7 @@ +using DataImport.Presentation.GenericDTO; +using MediatR; + +namespace DataImport.Commands.Queries; + +public record GetTopCountriesQuery(int Top = 6) : IRequest; +public record GetSdnTypeCountsQuery : IRequest>; \ No newline at end of file diff --git a/DataImport.Presentation/GenericDTO/StatsDTO.cs b/DataImport.Presentation/GenericDTO/StatsDTO.cs new file mode 100644 index 0000000..22b20e1 --- /dev/null +++ b/DataImport.Presentation/GenericDTO/StatsDTO.cs @@ -0,0 +1,7 @@ +namespace DataImport.Presentation.GenericDTO; + +public record CountryCountDto(string Country, int Count); +public record SdnTypeCountDto(string SdnType, int Count); + +// Unknown = rows with no country (some records have null); Other = everything outside the top N +public record TopCountriesDto(int Total, int Unknown, int Other, List Top); \ No newline at end of file