From e400be31f7a42f95bdc872eda4d49b8c49635286 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 1 Sep 2024 18:09:48 -0500 Subject: [PATCH] feat: complete end to end functionality --- .../Unit/AddressRepositoryTests.cs | 63 ---- .../Unit/AliasRepositoryTests.cs | 63 ---- .../Unit/CommentRepositoryTests.cs | 63 ---- .../Unit/EfUnitOfWorkTests.cs | 86 ----- .../Unit/SdnRepositoryTests.cs | 63 ---- .../Extensions/SearchRequestExtensions.cs | 10 + .../Interfaces/IOnspringService.cs | 2 + src/SanctionsSearch.Worker/Models/Address.cs | 29 +- src/SanctionsSearch.Worker/Models/Sdn.cs | 2 +- .../Models/SearchRequest.cs | 10 - .../Models/SearchResult.cs | 12 +- .../Options/OnspringOptions.cs | 5 - .../Persistence/EfRepository.cs | 37 +-- .../Persistence/EfUnitOfWork.cs | 27 +- src/SanctionsSearch.Worker/Program.cs | 2 + .../Services/OnspringService.cs | 294 ++++++++++++++++-- src/SanctionsSearch.Worker/Usings.cs | 1 + .../Workers/OnspringWorker.cs | 56 +++- .../appsettings.Example.json | 5 - 19 files changed, 376 insertions(+), 454 deletions(-) delete mode 100644 src/SanctionsSearch.Worker.Tests/Unit/AddressRepositoryTests.cs delete mode 100644 src/SanctionsSearch.Worker.Tests/Unit/AliasRepositoryTests.cs delete mode 100644 src/SanctionsSearch.Worker.Tests/Unit/CommentRepositoryTests.cs delete mode 100644 src/SanctionsSearch.Worker.Tests/Unit/EfUnitOfWorkTests.cs delete mode 100644 src/SanctionsSearch.Worker.Tests/Unit/SdnRepositoryTests.cs create mode 100644 src/SanctionsSearch.Worker/Extensions/SearchRequestExtensions.cs diff --git a/src/SanctionsSearch.Worker.Tests/Unit/AddressRepositoryTests.cs b/src/SanctionsSearch.Worker.Tests/Unit/AddressRepositoryTests.cs deleted file mode 100644 index bff3840..0000000 --- a/src/SanctionsSearch.Worker.Tests/Unit/AddressRepositoryTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace SanctionsSearch.Worker.Tests.Unit; - -public class AddressRepositoryTests -{ - private readonly Mock _contextMock = new(); - private readonly Mock> _loggerMock = new(); - private readonly AddressFaker _faker = new(); - private readonly AddressRepository _repository; - - public AddressRepositoryTests() - { - _repository = new AddressRepository(_contextMock.Object, _loggerMock.Object); - } - - [Fact] - public async Task Upsert_WhenExceptionIsThrown_ItShouldLogError() - { - var entity = _faker.Generate(); - var mockSet = new Mock>(); - - mockSet - .Setup(x => x.FindAsync(entity.Id)) - .Throws(); - - _contextMock - .Setup(x => x.Set
()) - .Returns(mockSet.Object); - - await _repository.Upsert(entity); - - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>() - ) - ); - } - - [Fact] - public async Task Find_WhenExceptionIsThrown_ItShouldReturnEmptyListAndLogError() - { - _contextMock - .Setup(x => x.Set
()) - .Throws(); - - var result = await _repository.Find(x => x.Id == 1); - - result.Should().BeEmpty(); - - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>() - ) - ); - } -} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker.Tests/Unit/AliasRepositoryTests.cs b/src/SanctionsSearch.Worker.Tests/Unit/AliasRepositoryTests.cs deleted file mode 100644 index 84ddb0c..0000000 --- a/src/SanctionsSearch.Worker.Tests/Unit/AliasRepositoryTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace SanctionsSearch.Worker.Tests.Unit; - -public class AliasRepositoryTests -{ - private readonly Mock _contextMock = new(); - private readonly Mock> _loggerMock = new(); - private readonly AliasFaker _faker = new(); - private readonly AliasRepository _repository; - - public AliasRepositoryTests() - { - _repository = new AliasRepository(_contextMock.Object, _loggerMock.Object); - } - - [Fact] - public async Task Upsert_WhenExceptionIsThrown_ItShouldLogError() - { - var entity = _faker.Generate(); - var mockSet = new Mock>(); - - mockSet - .Setup(x => x.FindAsync(entity.Id)) - .Throws(); - - _contextMock - .Setup(x => x.Set()) - .Returns(mockSet.Object); - - await _repository.Upsert(entity); - - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>() - ) - ); - } - - [Fact] - public async Task Find_WhenExceptionIsThrown_ItShouldReturnEmptyListAndLogError() - { - _contextMock - .Setup(x => x.Set()) - .Throws(); - - var result = await _repository.Find(x => x.Id == 1); - - result.Should().BeEmpty(); - - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>() - ) - ); - } -} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker.Tests/Unit/CommentRepositoryTests.cs b/src/SanctionsSearch.Worker.Tests/Unit/CommentRepositoryTests.cs deleted file mode 100644 index 0d3cdf9..0000000 --- a/src/SanctionsSearch.Worker.Tests/Unit/CommentRepositoryTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace SanctionsSearch.Worker.Tests.Unit; - -public class CommentRepositoryTests -{ - private readonly Mock _contextMock = new(); - private readonly Mock> _loggerMock = new(); - private readonly CommentFaker _faker = new(); - private readonly CommentRepository _repository; - - public CommentRepositoryTests() - { - _repository = new CommentRepository(_contextMock.Object, _loggerMock.Object); - } - - [Fact] - public async Task Upsert_WhenExceptionIsThrown_ItShouldLogError() - { - var entity = _faker.Generate(); - var mockSet = new Mock>(); - - mockSet - .Setup(x => x.FindAsync(entity.Id)) - .Throws(); - - _contextMock - .Setup(x => x.Set()) - .Returns(mockSet.Object); - - await _repository.Upsert(entity); - - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>() - ) - ); - } - - [Fact] - public async Task Find_WhenExceptionIsThrown_ItShouldReturnEmptyListAndLogError() - { - _contextMock - .Setup(x => x.Set()) - .Throws(); - - var result = await _repository.Find(x => x.Id == 1); - - result.Should().BeEmpty(); - - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>() - ) - ); - } -} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker.Tests/Unit/EfUnitOfWorkTests.cs b/src/SanctionsSearch.Worker.Tests/Unit/EfUnitOfWorkTests.cs deleted file mode 100644 index 3356acd..0000000 --- a/src/SanctionsSearch.Worker.Tests/Unit/EfUnitOfWorkTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -namespace SanctionsSearch.Worker.Tests.Unit; - -public class EfUnitOfWorkTests -{ - private readonly Mock _contextMock = new(); - private readonly Mock> _loggerMock = new(); - private readonly Mock _loggerFactoryMock = new(); - private readonly EfUnitOfWork _unitOfWork; - - public EfUnitOfWorkTests() - { - _unitOfWork = new(_contextMock.Object, _loggerMock.Object, _loggerFactoryMock.Object); - } - - [Fact] - public void Sdns_WhenCalled_ShouldReturnSdnRepository() - { - _unitOfWork.Sdns.Should().BeOfType(); - } - - [Fact] - public void Addresses_WhenCalled_ShouldReturnAddressRepository() - { - _unitOfWork.Addresses.Should().BeOfType(); - } - - [Fact] - public void Aliases_WhenCalled_ShouldReturnAliasRepository() - { - _unitOfWork.Aliases.Should().BeOfType(); - } - - [Fact] - public void Comments_WhenCalled_ShouldReturnCommentRepository() - { - _unitOfWork.Comments.Should().BeOfType(); - } - - [Fact] - public void SaveChangesAsync_WhenADatabaseUpdateExceptionIsThrown_ItShouldBeCaughtAndLogged() - { - _contextMock - .Setup(x => x.SaveChangesAsync(It.IsAny())) - .ThrowsAsync(new DbUpdateException()); - - var action = _unitOfWork.SaveChangesAsync; - - action.Should().NotThrowAsync(); - } - - [Fact] - public void SaveChangesAsync_WhenExceptionIsThrown_ItShouldNotBeCaught() - { - _contextMock - .Setup(x => x.SaveChangesAsync(It.IsAny())) - .ThrowsAsync(new Exception()); - - var action = _unitOfWork.SaveChangesAsync; - - action.Should().ThrowAsync(); - } - - [Fact] - public void DisposeAsync_WhenExceptionIsThrown_ItShouldBeCaught() - { - _contextMock - .Setup(x => x.DisposeAsync()) - .Throws(new Exception()); - - var action = async () => await _unitOfWork.DisposeAsync(); - - action.Should().NotThrowAsync(); - } - - [Fact] - public void Dispose_WhenExceptionIsThrown_ItShouldBeCaught() - { - _contextMock - .Setup(x => x.Dispose()) - .Throws(new Exception()); - - var action = _unitOfWork.Dispose; - - action.Should().NotThrow(); - } -} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker.Tests/Unit/SdnRepositoryTests.cs b/src/SanctionsSearch.Worker.Tests/Unit/SdnRepositoryTests.cs deleted file mode 100644 index 5d02f9e..0000000 --- a/src/SanctionsSearch.Worker.Tests/Unit/SdnRepositoryTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace SanctionsSearch.Worker.Tests.Unit; - -public class SdnRepositoryTests -{ - private readonly Mock _contextMock = new(); - private readonly Mock> _loggerMock = new(); - private readonly SdnFaker _faker = new(); - private readonly SdnRepository _repository; - - public SdnRepositoryTests() - { - _repository = new SdnRepository(_contextMock.Object, _loggerMock.Object); - } - - [Fact] - public async Task Upsert_WhenExceptionIsThrown_ItShouldLogError() - { - var entity = _faker.Generate(); - var mockSet = new Mock>(); - - mockSet - .Setup(x => x.FindAsync(entity.Id)) - .Throws(); - - _contextMock - .Setup(x => x.Set()) - .Returns(mockSet.Object); - - await _repository.Upsert(entity); - - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>() - ) - ); - } - - [Fact] - public async Task Find_WhenExceptionIsThrown_ItShouldReturnEmptyListAndLogError() - { - _contextMock - .Setup(x => x.Set()) - .Throws(); - - var result = await _repository.Find(x => x.Id == 1); - - result.Should().BeEmpty(); - - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>() - ) - ); - } -} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Extensions/SearchRequestExtensions.cs b/src/SanctionsSearch.Worker/Extensions/SearchRequestExtensions.cs new file mode 100644 index 0000000..ec8993f --- /dev/null +++ b/src/SanctionsSearch.Worker/Extensions/SearchRequestExtensions.cs @@ -0,0 +1,10 @@ +namespace SanctionsSearch.Worker.Extensions; + +static class SearchRequestExtensions +{ + public static Expression> ToSdnFilter(this SearchRequest request) + { + var nameParts = request.Name.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return sdn => nameParts.All(part => sdn.Name.Contains(part)); + } +} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Interfaces/IOnspringService.cs b/src/SanctionsSearch.Worker/Interfaces/IOnspringService.cs index 7b16ebb..79519b4 100644 --- a/src/SanctionsSearch.Worker/Interfaces/IOnspringService.cs +++ b/src/SanctionsSearch.Worker/Interfaces/IOnspringService.cs @@ -2,6 +2,8 @@ namespace SanctionsSearch.Worker.Interfaces; interface IOnspringService { + Task UpdateSearchRequestAsProcessingAsync(SearchRequest request); + Task UpdateSearchRequestAsFailedAsync(SearchRequest request, string error); Task> GetSearchRequestsAsync(); Task AddSearchResultAsync(SearchResult result); } \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Models/Address.cs b/src/SanctionsSearch.Worker/Models/Address.cs index 8c54f9d..bf69362 100644 --- a/src/SanctionsSearch.Worker/Models/Address.cs +++ b/src/SanctionsSearch.Worker/Models/Address.cs @@ -12,6 +12,33 @@ class Address : Entity public override string ToString() { - return $"{StreetAddress}, {CityProvincePostal}, {Country}"; + var address = new StringBuilder(); + + if (string.IsNullOrWhiteSpace(StreetAddress) is false) + { + address.Append(StreetAddress); + } + + if (string.IsNullOrWhiteSpace(CityProvincePostal) is false) + { + if (address.Length > 0) + { + address.Append(", "); + } + + address.Append(CityProvincePostal); + } + + if (string.IsNullOrWhiteSpace(Country) is false) + { + if (address.Length > 0) + { + address.Append(", "); + } + + address.Append(Country); + } + + return address.ToString(); } } \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Models/Sdn.cs b/src/SanctionsSearch.Worker/Models/Sdn.cs index b14dd72..dda6fc2 100644 --- a/src/SanctionsSearch.Worker/Models/Sdn.cs +++ b/src/SanctionsSearch.Worker/Models/Sdn.cs @@ -25,7 +25,7 @@ class Sdn : Entity Name = Name, Address = Addresses.FirstOrDefault()?.ToString() ?? string.Empty, Type = Type, - Programs = [.. Program.Split("] [")] + Programs = [.. Program.Split("] [")], }; } } \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Models/SearchRequest.cs b/src/SanctionsSearch.Worker/Models/SearchRequest.cs index 7458d94..8bcfb1d 100644 --- a/src/SanctionsSearch.Worker/Models/SearchRequest.cs +++ b/src/SanctionsSearch.Worker/Models/SearchRequest.cs @@ -4,14 +4,4 @@ class SearchRequest { public int Id { get; init; } public string Name { get; set; } = string.Empty; - public string Address { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; - public string State { get; set; } = string.Empty; - public string Zip { get; set; } = string.Empty; - public string Country { get; set; } = string.Empty; - - public Expression> ToSdnFilter() - { - return sdn => EF.Functions.Like(sdn.Name, $"%{Name}%"); - } } \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Models/SearchResult.cs b/src/SanctionsSearch.Worker/Models/SearchResult.cs index 27d6635..bf0926f 100644 --- a/src/SanctionsSearch.Worker/Models/SearchResult.cs +++ b/src/SanctionsSearch.Worker/Models/SearchResult.cs @@ -1,15 +1,9 @@ namespace SanctionsSearch.Worker.Models; -class SearchResult +class SearchResult(int searchRequestId, List hits) { - public int SearchRequestId { get; init; } - public List Hits { get; init; } = []; - - public SearchResult(int searchRequestId, List hits) - { - SearchRequestId = searchRequestId; - Hits = hits; - } + public int SearchRequestId { get; init; } = searchRequestId; + public List Hits { get; init; } = hits; } class Hit diff --git a/src/SanctionsSearch.Worker/Options/OnspringOptions.cs b/src/SanctionsSearch.Worker/Options/OnspringOptions.cs index e5aea4c..5179d15 100644 --- a/src/SanctionsSearch.Worker/Options/OnspringOptions.cs +++ b/src/SanctionsSearch.Worker/Options/OnspringOptions.cs @@ -13,11 +13,6 @@ class SearchRequestOptions { public int AppId { get; init; } public int NameFieldId { get; init; } - public int AddressFieldId { get; init; } - public int CityFieldId { get; init; } - public int StateFieldId { get; init; } - public int ZipFieldId { get; init; } - public int CountryFieldId { get; init; } public int StatusFieldId { get; init; } public Guid AwaitingProcessingStatusId { get; init; } public Guid ProcessingStatusId { get; init; } diff --git a/src/SanctionsSearch.Worker/Persistence/EfRepository.cs b/src/SanctionsSearch.Worker/Persistence/EfRepository.cs index fcb03f6..01c741a 100644 --- a/src/SanctionsSearch.Worker/Persistence/EfRepository.cs +++ b/src/SanctionsSearch.Worker/Persistence/EfRepository.cs @@ -13,41 +13,26 @@ class EfRepository : IRepository where T : Entity public async Task> Find(Expression> predicate, params Expression>[]? includes) { - try - { - var query = _context.Set().AsQueryable(); + var query = _context.Set().AsQueryable(); - if (includes is not null) - { - query = includes.Aggregate(query, (current, include) => current.Include(include)); - } - - return await query.Where(predicate).ToListAsync(); - } - catch (Exception ex) + if (includes is not null) { - _logger.LogError(ex, "Error finding entities of type {Type}", typeof(T).Name); - return []; + query = includes.Aggregate(query, (current, include) => current.Include(include)); } + + return await query.Where(predicate).ToListAsync(); } public async Task Upsert(T entity) { - try - { - var existing = await _context.Set().FindAsync(entity.Id); + var existing = await _context.Set().FindAsync(entity.Id); - if (existing is null) - { - await _context.Set().AddAsync(entity); - return; - } - - _context.Entry(existing).CurrentValues.SetValues(entity); - } - catch (Exception ex) + if (existing is null) { - _logger.LogError(ex, "Error upserting entity of type {Type} with {Id}", typeof(T).Name, entity.Id); + await _context.Set().AddAsync(entity); + return; } + + _context.Entry(existing).CurrentValues.SetValues(entity); } } \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Persistence/EfUnitOfWork.cs b/src/SanctionsSearch.Worker/Persistence/EfUnitOfWork.cs index e688d30..f7aa6d7 100644 --- a/src/SanctionsSearch.Worker/Persistence/EfUnitOfWork.cs +++ b/src/SanctionsSearch.Worker/Persistence/EfUnitOfWork.cs @@ -15,37 +15,16 @@ class EfUnitOfWork( public async Task SaveChangesAsync() { - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Failed to save changes to the database."); - } + await _context.SaveChangesAsync(); } public async ValueTask DisposeAsync() { - try - { - await _context.DisposeAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to dispose of the database context."); - } + await _context.DisposeAsync(); } public void Dispose() { - try - { - _context.Dispose(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to dispose of the database context."); - } + _context.Dispose(); } } \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Program.cs b/src/SanctionsSearch.Worker/Program.cs index 2c1d0d3..e26ba7c 100644 --- a/src/SanctionsSearch.Worker/Program.cs +++ b/src/SanctionsSearch.Worker/Program.cs @@ -81,6 +81,7 @@ class Program builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -91,6 +92,7 @@ class Program builder.Services.AddScoped(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); return builder; } diff --git a/src/SanctionsSearch.Worker/Services/OnspringService.cs b/src/SanctionsSearch.Worker/Services/OnspringService.cs index 5a8a015..ae7c4ce 100644 --- a/src/SanctionsSearch.Worker/Services/OnspringService.cs +++ b/src/SanctionsSearch.Worker/Services/OnspringService.cs @@ -1,4 +1,3 @@ - namespace SanctionsSearch.Worker.Services; class OnspringService( @@ -11,14 +10,139 @@ class OnspringService( private readonly ILogger _logger = logger; private readonly IOnspringClient _client = client; - public Task AddSearchResultAsync(SearchResult result) + public async Task AddSearchResultAsync(SearchResult result) { - // TODO: Implement this method - // - we should add a new record for each hit - // - each new hit record should reference the request - // - we should update the request status to processed - // - we should return a Result indicating success or failure - throw new NotImplementedException(); + var typeValues = result.Hits.Select(h => h.Type).ToList(); + var typeFieldPairs = await GetOrAddListValuePairs( + _options.SearchResultOptions.TypeFieldId, + typeValues + ); + + var programValues = result.Hits.SelectMany(h => h.Programs).ToList(); + var programFieldPairs = await GetOrAddListValuePairs( + _options.SearchResultOptions.ProgramsFieldId, + programValues + ); + + var resultRecords = result.Hits.Select(hit => + { + var resultRecord = new ResultRecord() + { + AppId = _options.SearchResultOptions.AppId, + FieldData = [ + new IntegerFieldValue() + { + FieldId = _options.SearchResultOptions.SearchRequestFieldId, + Value = result.SearchRequestId + }, + new StringFieldValue() + { + FieldId = _options.SearchResultOptions.NameFieldId, + Value = hit.Name + }, + new StringFieldValue() + { + FieldId = _options.SearchResultOptions.AddressFieldId, + Value = hit.Address + }, + ] + }; + + if (string.IsNullOrWhiteSpace(hit.Type) is false) + { + resultRecord.FieldData.Add( + new GuidFieldValue() + { + FieldId = _options.SearchResultOptions.TypeFieldId, + Value = typeFieldPairs[hit.Type] + } + ); + } + + var programValues = hit.Programs + .Where(p => string.IsNullOrWhiteSpace(p) is false) + .Select(p => programFieldPairs[p]) + .ToList(); + + if (programValues.Any()) + { + resultRecord.FieldData.Add( + new GuidListFieldValue() + { + FieldId = _options.SearchResultOptions.ProgramsFieldId, + Value = programValues + } + ); + } + + return resultRecord; + }); + + var saveResultRequests = resultRecords.Select(_client.SaveRecordAsync); + var saveResultResponses = await Task.WhenAll(saveResultRequests); + + foreach (var response in saveResultResponses) + { + if (response.IsSuccessful is false) + { + _logger.LogError( + "Failed to save search result: {StatusCode} - {Error}", + response.StatusCode, + response.Message + ); + } + } + + var isFailed = saveResultResponses.Any(r => r.IsSuccessful is false); + var updatedSearchRequest = isFailed + ? new ResultRecord() + { + AppId = _options.SearchRequestOptions.AppId, + RecordId = result.SearchRequestId, + FieldData = [ + new GuidFieldValue() + { + FieldId = _options.SearchRequestOptions.StatusFieldId, + Value = _options.SearchRequestOptions.ProcessedErrorStatusId + }, + new StringFieldValue() + { + FieldId = _options.SearchRequestOptions.ErrorFieldId, + Value = "Unable to save all search results" + } + ] + } + : new ResultRecord() + { + AppId = _options.SearchRequestOptions.AppId, + RecordId = result.SearchRequestId, + FieldData = [ + new GuidFieldValue() + { + FieldId = _options.SearchRequestOptions.StatusFieldId, + Value = _options.SearchRequestOptions.ProcessedSuccessStatusId + }, + new StringFieldValue() + { + FieldId = _options.SearchRequestOptions.ErrorFieldId, + Value = string.Empty + } + ] + }; + + var updateRequestResponse = await _client.SaveRecordAsync(updatedSearchRequest); + + if (updateRequestResponse.IsSuccessful is false) + { + _logger.LogError( + "Failed to update search request {RequestId} status: {StatusCode} - {Error}", + result.SearchRequestId, + updateRequestResponse.StatusCode, + updateRequestResponse.Message + ); + } + + return isFailed ? Result.Fail("Failed to saving all search results") : Result.Ok(); } public async Task> GetSearchRequestsAsync() @@ -29,11 +153,6 @@ class OnspringService( Filter = $"{_options.SearchRequestOptions.StatusFieldId} contains '{_options.SearchRequestOptions.AwaitingProcessingStatusId}'", FieldIds = [ _options.SearchRequestOptions.NameFieldId, - _options.SearchRequestOptions.AddressFieldId, - _options.SearchRequestOptions.CityFieldId, - _options.SearchRequestOptions.StateFieldId, - _options.SearchRequestOptions.ZipFieldId, - _options.SearchRequestOptions.CountryFieldId ], }; @@ -89,6 +208,135 @@ class OnspringService( return records.Select(MapRecordToSearchRequest).ToList(); } + public async Task UpdateSearchRequestAsFailedAsync(SearchRequest request, string error) + { + var updatedSearchRequest = new ResultRecord() + { + AppId = _options.SearchRequestOptions.AppId, + RecordId = request.Id, + FieldData = [ + new GuidFieldValue() + { + FieldId = _options.SearchRequestOptions.StatusFieldId, + Value = _options.SearchRequestOptions.ProcessedErrorStatusId + }, + new StringFieldValue() + { + FieldId = _options.SearchRequestOptions.ErrorFieldId, + Value = error + } + ] + }; + + var updateRequestResponse = await _client.SaveRecordAsync(updatedSearchRequest); + + if (updateRequestResponse.IsSuccessful is false) + { + _logger.LogError( + "Failed to update search request {RequestId} status: {StatusCode} - {Error}", + request.Id, + updateRequestResponse.StatusCode, + updateRequestResponse.Message + ); + + return Result.Fail("Failed to update search request status"); + } + + return Result.Ok(); + } + + public async Task UpdateSearchRequestAsProcessingAsync(SearchRequest request) + { + var updatedSearchRequest = new ResultRecord() + { + AppId = _options.SearchRequestOptions.AppId, + RecordId = request.Id, + FieldData = [ + new GuidFieldValue() + { + FieldId = _options.SearchRequestOptions.StatusFieldId, + Value = _options.SearchRequestOptions.ProcessingStatusId + } + ] + }; + + var updateRequestResponse = await _client.SaveRecordAsync(updatedSearchRequest); + + if (updateRequestResponse.IsSuccessful is false) + { + _logger.LogError( + "Failed to update search request {RequestId} status: {StatusCode} - {Error}", + request.Id, + updateRequestResponse.StatusCode, + updateRequestResponse.Message + ); + + return Result.Fail("Failed to update search request status"); + } + + return Result.Ok(); + } + + private async Task> GetOrAddListValuePairs(int listFieldId, List values) + { + var pairs = new Dictionary(); + + var getFieldResponse = await _client.GetFieldAsync(listFieldId); + + if (getFieldResponse.IsSuccessful is false) + { + _logger.LogError( + "Failed to retrieve field information for list field with id {ListFieldId}: {StatusCode} - {Error}", + listFieldId, + getFieldResponse.StatusCode, + getFieldResponse.Message + ); + + return pairs; + } + + if (getFieldResponse.Value is not ListField listField) + { + _logger.LogError("Field with id {ListFieldId} is not a list field", listFieldId); + return pairs; + } + + foreach (var value in values.Where(v => string.IsNullOrWhiteSpace(v) is false).Distinct()) + { + var existingListFieldValue = listField.Values.FirstOrDefault(v => string.Equals(v.Name, value, StringComparison.InvariantCultureIgnoreCase)); + + if (existingListFieldValue is not null) + { + pairs.Add(value, existingListFieldValue.Id); + continue; + } + + var saveListFieldValueRequest = new SaveListItemRequest() + { + ListId = listField.ListId, + Name = value + }; + + var saveListFieldValueResponse = await _client.SaveListItemAsync(saveListFieldValueRequest); + + if (saveListFieldValueResponse.IsSuccessful is false) + { + _logger.LogError( + "Failed to save list field value for list field with id {ListFieldId}: {StatusCode} - {Error}", + listFieldId, + saveListFieldValueResponse.StatusCode, + saveListFieldValueResponse.Message + ); + + continue; + } + + pairs.Add(value, saveListFieldValueResponse.Value.Id); + } + + return pairs; + } + private SearchRequest MapRecordToSearchRequest(ResultRecord record) { var searchRequest = new SearchRequest @@ -102,26 +350,6 @@ class OnspringService( { searchRequest.Name = field.GetStringValue(); } - else if (field.FieldId == _options.SearchRequestOptions.AddressFieldId) - { - searchRequest.Address = field.GetStringValue(); - } - else if (field.FieldId == _options.SearchRequestOptions.CityFieldId) - { - searchRequest.City = field.GetStringValue(); - } - else if (field.FieldId == _options.SearchRequestOptions.StateFieldId) - { - searchRequest.State = field.GetStringValue(); - } - else if (field.FieldId == _options.SearchRequestOptions.ZipFieldId) - { - searchRequest.Zip = field.GetStringValue(); - } - else if (field.FieldId == _options.SearchRequestOptions.CountryFieldId) - { - searchRequest.Country = field.GetStringValue(); - } } return searchRequest; diff --git a/src/SanctionsSearch.Worker/Usings.cs b/src/SanctionsSearch.Worker/Usings.cs index 959c9c5..52b95dd 100644 --- a/src/SanctionsSearch.Worker/Usings.cs +++ b/src/SanctionsSearch.Worker/Usings.cs @@ -2,6 +2,7 @@ global using System.Collections.Concurrent; global using System.Globalization; global using System.Linq.Expressions; global using System.Reflection; +global using System.Text; global using CsvHelper; global using CsvHelper.Configuration; diff --git a/src/SanctionsSearch.Worker/Workers/OnspringWorker.cs b/src/SanctionsSearch.Worker/Workers/OnspringWorker.cs index 57232af..5159c32 100644 --- a/src/SanctionsSearch.Worker/Workers/OnspringWorker.cs +++ b/src/SanctionsSearch.Worker/Workers/OnspringWorker.cs @@ -42,10 +42,62 @@ public class OnspringWorker( GC.SuppressFinalize(this); } - private Task RunSearchRequests() + private async Task RunSearchRequests() { + var searchBatchIdProperty = LogContext.PushProperty("SearchBatchId", Guid.NewGuid()); + _logger.LogInformation("Running search requests"); - return Task.CompletedTask; + try + { + using var scope = _serviceScopeFactory.CreateAsyncScope(); + var onspringService = scope.ServiceProvider.GetRequiredService(); + var searchService = scope.ServiceProvider.GetRequiredService(); + + var searchRequests = await onspringService.GetSearchRequestsAsync(); + + if (searchRequests.Count is 0) + { + _logger.LogInformation("No search requests found"); + return; + } + + foreach (var searchRequest in searchRequests) + { + var searchBatchItemIdProperty = LogContext.PushProperty("SearchBatchItemId", Guid.NewGuid()); + + try + { + _logger.LogInformation("Processing search request {SearchRequestId}", searchRequest.Id); + + await onspringService.UpdateSearchRequestAsProcessingAsync(searchRequest); + var searchResult = await searchService.PerformSearchAsync(searchRequest); + await onspringService.AddSearchResultAsync(searchResult); + + _logger.LogInformation("Search request {SearchRequestId} processed", searchRequest.Id); + } + catch (Exception ex) + { + await onspringService.UpdateSearchRequestAsFailedAsync( + searchRequest, + $"Failed to process search request: {ex.Message}" + ); + + _logger.LogError(ex, "Failed to process search request {SearchRequestId}", searchRequest.Id); + } + finally + { + searchBatchItemIdProperty.Dispose(); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to run search requests"); + } + finally + { + searchBatchIdProperty.Dispose(); + } } } \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/appsettings.Example.json b/src/SanctionsSearch.Worker/appsettings.Example.json index 384dd61..768b6be 100644 --- a/src/SanctionsSearch.Worker/appsettings.Example.json +++ b/src/SanctionsSearch.Worker/appsettings.Example.json @@ -27,11 +27,6 @@ "SearchRequestOptions": { "AppId": 949, "NameFieldId": 20946, - "AddressFieldId": 20947, - "CityFieldId": 20948, - "StateFieldId": 20949, - "ZipFieldId": 20967, - "CountryFieldId": 20950, "StatusFieldId": 20964, "AwaitingProcessingStatusId": "3cea19f0-afae-4dac-ac5b-e6f5555659b3", "ProcessingStatusId": "597c3096-ab0e-4910-a7a9-98d149acee81",