From 904ab9e30ba39179daf0d571e2b87f8239b4ea22 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Thu, 8 Aug 2024 20:15:24 +0300 Subject: [PATCH 01/20] Don't show "submitted by" user email public for privacy reasons. (#88) --- Website/Pages/SearchResults.cshtml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Website/Pages/SearchResults.cshtml b/Website/Pages/SearchResults.cshtml index 6260ff4..766fc8e 100644 --- a/Website/Pages/SearchResults.cshtml +++ b/Website/Pages/SearchResults.cshtml @@ -29,12 +29,7 @@ {

@name.Name

-

Brief Meaning: @name.Meaning

- -

- @Localizer["submitby"] @name.SubmittedBy -

} } From 2d2b9eae89ec36efeb422b21fee045cce864431d Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Thu, 8 Aug 2024 20:45:52 +0300 Subject: [PATCH 02/20] Let the variants list link to the variant name. (#89) --- Website/Pages/SingleEntry.cshtml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Website/Pages/SingleEntry.cshtml b/Website/Pages/SingleEntry.cshtml index e9d7fd9..b04ab38 100644 --- a/Website/Pages/SingleEntry.cshtml +++ b/Website/Pages/SingleEntry.cshtml @@ -45,7 +45,7 @@

@Localizer["extendedmeaningof"]

@Model.Name.ExtendedMeaning

} - + @if (Model.Name.Videos.Any()) {
@@ -134,7 +134,11 @@

@Localizer["variants"]

-

@Model.Name.Variants

+ + foreach (var variant in (List)Model.Name.Variants) + { +

@variant

+ } } From 127767e6ae6887d47cdc1f7f44edf730c48674a7 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Thu, 15 Aug 2024 22:11:29 +0300 Subject: [PATCH 03/20] Allow privileged users to manage GeoLocations list. (#91) * Use fixed format for logger. * Implement endpoints to CREATE and DELETE geolocations. * Improve README file. --- Api/Controllers/GeoLocationsController.cs | 29 +++++++++++- .../GlobalExceptionHandling.cs | 11 ++--- Application/Mappers/NameEntryMapper.cs | 2 +- Application/Mappers/SuggestedNameMapper.cs | 2 +- Application/Services/GeoLocationsService.cs | 35 ++++++++++++-- Application/Services/NameEntryService.cs | 2 +- .../Validation/GeoLocationValidator.cs | 2 +- Core/Dto/Request/CreateGeoLocationDto.cs | 6 +++ Core/Dto/Request/CreateSuggestedNameDto.cs | 2 + Core/Dto/Request/GeoLocationDto.cs | 11 ----- Core/Dto/Request/NameDto.cs | 3 +- Core/Dto/Response/GeoLocationDto.cs | 10 ++++ Core/Dto/Response/SuggestedNameDto.cs | 4 +- Core/Repositories/IGeoLocationsRepository.cs | 7 +-- .../Repositories/GeoLocationsRepository.cs | 47 +++++++++++++++---- README.md | 3 +- Website/Pages/SubmitName.cshtml.cs | 2 +- Website/Services/ApiService.cs | 3 +- YorubaNameDictionary.sln | 1 + 19 files changed, 133 insertions(+), 49 deletions(-) create mode 100644 Core/Dto/Request/CreateGeoLocationDto.cs delete mode 100644 Core/Dto/Request/GeoLocationDto.cs create mode 100644 Core/Dto/Response/GeoLocationDto.cs diff --git a/Api/Controllers/GeoLocationsController.cs b/Api/Controllers/GeoLocationsController.cs index 7e44b94..8d4c5a9 100644 --- a/Api/Controllers/GeoLocationsController.cs +++ b/Api/Controllers/GeoLocationsController.cs @@ -1,6 +1,9 @@ -using Application.Services; +using Api.Utilities; +using Application.Services; using Core.Dto.Request; +using Core.Dto.Response; using Core.Entities; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Net; @@ -8,6 +11,7 @@ namespace Api.Controllers { [Route("api/v1/[controller]")] [ApiController] + [Authorize(Policy = "AdminAndProLexicographers")] public class GeoLocationsController : ControllerBase { private readonly GeoLocationsService _geoLocationsService; @@ -23,11 +27,32 @@ public GeoLocationsController(GeoLocationsService geoLocationsService) /// An representing the response containing the list of objects. /// [HttpGet] + [AllowAnonymous] [ProducesResponseType(typeof(GeoLocationDto[]), (int)HttpStatusCode.OK)] public async Task ListGeoLocations() { - var result = (await _geoLocationsService.GetAll()).Select(g => new GeoLocationDto(g.Place, g.Region)); + var result = (await _geoLocationsService.GetAll()).Select(g => new GeoLocationDto(g.Id, g.Place, g.Region)); return Ok(result); } + + [HttpPost] + [ProducesResponseType(typeof(GeoLocationDto), (int)HttpStatusCode.OK)] + public async Task Create(CreateGeoLocationDto geo) + { + var geoLocation = new GeoLocation(geo.Place, geo.Region) + { + CreatedBy = User!.Identity!.Name! + }; + await _geoLocationsService.Create(geoLocation); + return StatusCode((int)HttpStatusCode.Created, ResponseHelper.GetResponseDict("Geolocation successfully added")); + } + + [HttpDelete("{id}/{place}")] + [ProducesResponseType(typeof(GeoLocationDto), (int)HttpStatusCode.OK)] + public async Task Delete(string id, string place) + { + await _geoLocationsService.Delete(id, place); + return Ok(ResponseHelper.GetResponseDict($"Geolocation '{place}' successfully deleted")); + } } } diff --git a/Api/ExceptionHandler/GlobalExceptionHandling.cs b/Api/ExceptionHandler/GlobalExceptionHandling.cs index 8a9d13f..3f18d1c 100644 --- a/Api/ExceptionHandler/GlobalExceptionHandling.cs +++ b/Api/ExceptionHandler/GlobalExceptionHandling.cs @@ -1,5 +1,6 @@ namespace Api.ExceptionHandler { + using Api.Utilities; using Application.Exceptions; using System.Net; using System.Text.Json; @@ -32,21 +33,17 @@ private async Task HandleExceptionAsync(HttpContext context, Exception exception context.Response.ContentType = "application/json"; var response = context.Response; - var errorResponse = new ErrorResponse - { - Success = false - }; + Dictionary errorResponse; switch (exception) { case ClientException ex: response.StatusCode = (int)HttpStatusCode.BadRequest; - errorResponse.Message = ex.Message; + errorResponse = ResponseHelper.GetResponseDict(ex.Message); break; - default: response.StatusCode = (int)HttpStatusCode.InternalServerError; - errorResponse.Message = "Internal server error!"; + errorResponse = ResponseHelper.GetResponseDict("Internal server error!"); break; } _logger.LogError(exception, "Unhandled Application Exception"); diff --git a/Application/Mappers/NameEntryMapper.cs b/Application/Mappers/NameEntryMapper.cs index 7a00260..36b6b92 100644 --- a/Application/Mappers/NameEntryMapper.cs +++ b/Application/Mappers/NameEntryMapper.cs @@ -59,7 +59,7 @@ public static NameEntryDto MapToDto(this NameEntry nameEntry) Meaning = nameEntry.Meaning, ExtendedMeaning = nameEntry.ExtendedMeaning, Morphology = (CommaSeparatedString)nameEntry.Morphology, - GeoLocation = nameEntry.GeoLocation.Select(ge => new GeoLocationDto(ge.Place, ge.Region)).ToList(), + GeoLocation = nameEntry.GeoLocation.Select(ge => new GeoLocationDto(ge.Id, ge.Place, ge.Region)).ToList(), FamousPeople = (CommaSeparatedString)nameEntry.FamousPeople, Media = (CommaSeparatedString)nameEntry.Media, SubmittedBy = nameEntry.CreatedBy, diff --git a/Application/Mappers/SuggestedNameMapper.cs b/Application/Mappers/SuggestedNameMapper.cs index 8f9422d..b09e2fa 100644 --- a/Application/Mappers/SuggestedNameMapper.cs +++ b/Application/Mappers/SuggestedNameMapper.cs @@ -36,7 +36,7 @@ public static SuggestedNameDto MapToDto(this SuggestedName request) Name = request.Name, Email = request.Email, Details = request.Details, - GeoLocation = request.GeoLocation.Select(ge => new GeoLocationDto(ge.Place, ge.Region)).ToList(), + GeoLocation = request.GeoLocation.Select(ge => new GeoLocationDto(ge.Id, ge.Place, ge.Region)).ToList(), }; } } diff --git a/Application/Services/GeoLocationsService.cs b/Application/Services/GeoLocationsService.cs index 1fad5b8..e09a01f 100644 --- a/Application/Services/GeoLocationsService.cs +++ b/Application/Services/GeoLocationsService.cs @@ -1,6 +1,6 @@ -using Core.Entities; +using Application.Exceptions; +using Core.Entities; using Core.Repositories; -using Application.Exceptions; namespace Application.Services { @@ -14,7 +14,36 @@ public GeoLocationsService(IGeoLocationsRepository geoLocationsRepository) } public async Task> GetAll() { - return await _geoLocationsRepository.GetAll(); + return await _geoLocationsRepository.GetAll(); + } + public async Task Create(GeoLocation geoLocation) + { + var match = await _geoLocationsRepository.FindByPlace(geoLocation.Place); + if (match != null) + { + throw new ClientException("This location already exists."); + } + + await _geoLocationsRepository.Create(new GeoLocation + { + Place = geoLocation.Place.Trim().ToUpper(), + Region = geoLocation.Region.Trim().ToUpper(), + CreatedBy = geoLocation.CreatedBy, + }); + } + + public async Task Delete(string id, string place) + { + if (string.IsNullOrWhiteSpace(place) || string.IsNullOrWhiteSpace(id)) + { + throw new ClientException("One or more input parameters are not valid."); + } + + var deleteCount = await _geoLocationsRepository.Delete(id, place); + if (deleteCount == 0) + { + throw new ClientException("No matching records were found to delete."); + } } } } diff --git a/Application/Services/NameEntryService.cs b/Application/Services/NameEntryService.cs index 8f22bd0..b1c8c12 100644 --- a/Application/Services/NameEntryService.cs +++ b/Application/Services/NameEntryService.cs @@ -34,7 +34,7 @@ public async Task Create(NameEntry entry) { existingName.Duplicates.Add(entry); await UpdateName(existingName); - _logger.LogWarning($"Someone attempted to create a new name over existing name: {name}."); + _logger.LogWarning("Someone attempted to create a new name over existing name: {name}.", name); return; } diff --git a/Application/Validation/GeoLocationValidator.cs b/Application/Validation/GeoLocationValidator.cs index 8dbb1bd..1a002b1 100644 --- a/Application/Validation/GeoLocationValidator.cs +++ b/Application/Validation/GeoLocationValidator.cs @@ -1,4 +1,4 @@ -using Core.Dto.Request; +using Core.Dto.Response; using Core.Repositories; using FluentValidation; using System; diff --git a/Core/Dto/Request/CreateGeoLocationDto.cs b/Core/Dto/Request/CreateGeoLocationDto.cs new file mode 100644 index 0000000..e334b9c --- /dev/null +++ b/Core/Dto/Request/CreateGeoLocationDto.cs @@ -0,0 +1,6 @@ +namespace Core.Dto.Request +{ + public record CreateGeoLocationDto(string Place, string Region) + { + } +} diff --git a/Core/Dto/Request/CreateSuggestedNameDto.cs b/Core/Dto/Request/CreateSuggestedNameDto.cs index 7b53430..4d9b021 100644 --- a/Core/Dto/Request/CreateSuggestedNameDto.cs +++ b/Core/Dto/Request/CreateSuggestedNameDto.cs @@ -1,4 +1,6 @@  +using Core.Dto.Response; + namespace Core.Dto.Request; public record CreateSuggestedNameDto diff --git a/Core/Dto/Request/GeoLocationDto.cs b/Core/Dto/Request/GeoLocationDto.cs deleted file mode 100644 index 0f797d0..0000000 --- a/Core/Dto/Request/GeoLocationDto.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Core.Dto.Request -{ - public record GeoLocationDto(string Place, string Region) - { - public override string ToString() - { - return Place; - } - } - -} diff --git a/Core/Dto/Request/NameDto.cs b/Core/Dto/Request/NameDto.cs index 4ab175d..6df72c4 100644 --- a/Core/Dto/Request/NameDto.cs +++ b/Core/Dto/Request/NameDto.cs @@ -1,4 +1,5 @@ -using Core.Enums; +using Core.Dto.Response; +using Core.Enums; using System.ComponentModel.DataAnnotations; namespace Core.Dto.Request diff --git a/Core/Dto/Response/GeoLocationDto.cs b/Core/Dto/Response/GeoLocationDto.cs new file mode 100644 index 0000000..99441ae --- /dev/null +++ b/Core/Dto/Response/GeoLocationDto.cs @@ -0,0 +1,10 @@ +namespace Core.Dto.Response +{ + public record GeoLocationDto(string Id, string Place, string Region) + { + public override string ToString() + { + return Place; + } + } +} diff --git a/Core/Dto/Response/SuggestedNameDto.cs b/Core/Dto/Response/SuggestedNameDto.cs index 5f6927f..c1d1cc8 100644 --- a/Core/Dto/Response/SuggestedNameDto.cs +++ b/Core/Dto/Response/SuggestedNameDto.cs @@ -1,6 +1,4 @@ -using Core.Dto.Request; - -namespace Core.Dto.Response; +namespace Core.Dto.Response; public record SuggestedNameDto { diff --git a/Core/Repositories/IGeoLocationsRepository.cs b/Core/Repositories/IGeoLocationsRepository.cs index 6eca5b5..3f826ec 100644 --- a/Core/Repositories/IGeoLocationsRepository.cs +++ b/Core/Repositories/IGeoLocationsRepository.cs @@ -1,9 +1,4 @@ using Core.Entities; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Core.Repositories { @@ -13,5 +8,7 @@ public interface IGeoLocationsRepository Task FindByPlace(string place); Task FindByPlaceAndRegion(string region, string place); + Task Create(GeoLocation geoLocation); + Task Delete(string id, string place); } } diff --git a/Infrastructure.MongoDB/Repositories/GeoLocationsRepository.cs b/Infrastructure.MongoDB/Repositories/GeoLocationsRepository.cs index e4f6567..c50ccc5 100644 --- a/Infrastructure.MongoDB/Repositories/GeoLocationsRepository.cs +++ b/Infrastructure.MongoDB/Repositories/GeoLocationsRepository.cs @@ -5,7 +5,7 @@ namespace Infrastructure.MongoDB.Repositories { - public class GeoLocationsRepository : IGeoLocationsRepository + public class GeoLocationsRepository : MongoDBRepository, IGeoLocationsRepository { private readonly IMongoCollection _geoLocationsCollection; @@ -18,25 +18,47 @@ public GeoLocationsRepository(IMongoDatabase database) InitGeoLocation(); } } - + public async Task FindByPlace(string place) { - var filter = Builders.Filter.Eq("Place", place); - return await _geoLocationsCollection.Find(filter).SingleOrDefaultAsync(); + var filter = Builders.Filter.Eq( ge => ge.Place, place); + var options = SetCollationPrimary(new FindOptions()); + return await _geoLocationsCollection.Find(filter, options).SingleOrDefaultAsync(); } public async Task FindByPlaceAndRegion(string region, string place) { var filter = Builders.Filter.And( - Builders.Filter.Eq("Region", region.ToUpper()), - Builders.Filter.Eq("Place", place.ToUpper()) + Builders.Filter.Eq(ge => ge.Region, region), + Builders.Filter.Eq(ge => ge.Place, place) ); - return await _geoLocationsCollection.Find(filter).FirstOrDefaultAsync(); + var options = SetCollationPrimary(new FindOptions()); + return await _geoLocationsCollection.Find(filter, options).FirstOrDefaultAsync(); } - public async Task> GetAll() + public async Task> GetAll() => await _geoLocationsCollection + .Find(Builders.Filter.Empty) + .Sort(Builders.Sort.Ascending(g => g.Place)) + .ToListAsync(); + + public async Task Create(GeoLocation geoLocation) { - return await _geoLocationsCollection.Find(FilterDefinition.Empty).ToListAsync(); + geoLocation.Id = ObjectId.GenerateNewId().ToString(); + await _geoLocationsCollection.InsertOneAsync(geoLocation); + } + + public async Task Delete(string id, string place) + { + var filterBuilder = Builders.Filter; + + var filter = filterBuilder.And( + filterBuilder.Eq(g => g.Id, id), + filterBuilder.Eq(g => g.Place, place) + ); + + var options = SetCollationPrimary(new DeleteOptions()); + var deleteResult = await _geoLocationsCollection.DeleteOneAsync(filter, options); + return (int)deleteResult.DeletedCount; } private void InitGeoLocation() @@ -65,6 +87,12 @@ private void InitGeoLocation() Region = "OYO" }, new() + { + Id = ObjectId.GenerateNewId().ToString(), + Place = "OSUN", + Region = "OYO" + }, + new() { Id = ObjectId.GenerateNewId().ToString(), Place = "OGUN", @@ -186,5 +214,6 @@ private void InitGeoLocation() } }); } + } } diff --git a/README.md b/README.md index fd63673..f528172 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ To run the Website and API locally, follow these steps: 5. **Access the Application:** - Once the Docker containers are up and running, you can access the Website in your browser. The website should launch automatically as soon as all the containers are ready. - If the Website does not launch automatically, it will be running at `http://localhost:{port}` (the actual port will be displayed in the output window of Visual Studio: "Container Tools"). - - The API will also be running locally and accessible via a different URL. You can see its documentation and test the endpoints at `http://localhost:51515/swagger`. + - The API will also be running locally and accessible via a different URL. You can see its documentation and test the endpoints at `http://localhost:51515/swagger`. + - You can login to the API with any username from [the database initialization script](./mongo-init.js) and password: **Password@135**. ## Contributing diff --git a/Website/Pages/SubmitName.cshtml.cs b/Website/Pages/SubmitName.cshtml.cs index 35c0ba3..886918d 100644 --- a/Website/Pages/SubmitName.cshtml.cs +++ b/Website/Pages/SubmitName.cshtml.cs @@ -1,5 +1,5 @@ using Application.Services; -using Core.Dto.Request; +using Core.Dto.Response; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; using Website.Pages.Shared; diff --git a/Website/Services/ApiService.cs b/Website/Services/ApiService.cs index 43f0ec3..e924411 100644 --- a/Website/Services/ApiService.cs +++ b/Website/Services/ApiService.cs @@ -1,5 +1,4 @@ -using Core.Dto.Request; -using Core.Dto.Response; +using Core.Dto.Response; using Microsoft.Extensions.Options; using System.Text.Json; using Website.Config; diff --git a/YorubaNameDictionary.sln b/YorubaNameDictionary.sln index a2cd3e4..4da7efc 100644 --- a/YorubaNameDictionary.sln +++ b/YorubaNameDictionary.sln @@ -24,6 +24,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{6626E809-1AC8-46E8-BBAF-1862FBC64D3D}" ProjectSection(SolutionItems) = preProject mongo-init.js = mongo-init.js + README.md = README.md EndProjectSection EndProject Global From f7475da15aeffc04434036a92fc41f61336faba3 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Thu, 15 Aug 2024 23:11:34 +0300 Subject: [PATCH 04/20] Use the CreateDto in all POST/PUT requests to avoid validation problems. (#92) --- Application/Validation/CreateSuggestedNameValidator.cs | 2 -- Application/Validation/GeoLocationValidator.cs | 9 ++------- Core/Dto/Request/CreateSuggestedNameDto.cs | 9 +++------ Core/Dto/Request/NameDto.cs | 10 ++++------ 4 files changed, 9 insertions(+), 21 deletions(-) diff --git a/Application/Validation/CreateSuggestedNameValidator.cs b/Application/Validation/CreateSuggestedNameValidator.cs index 6c52f1d..2b44837 100644 --- a/Application/Validation/CreateSuggestedNameValidator.cs +++ b/Application/Validation/CreateSuggestedNameValidator.cs @@ -1,6 +1,4 @@ using Core.Dto.Request; -using Core.Dto.Response; -using Core.Enums; using FluentValidation; namespace Application.Validation diff --git a/Application/Validation/GeoLocationValidator.cs b/Application/Validation/GeoLocationValidator.cs index 1a002b1..233e46e 100644 --- a/Application/Validation/GeoLocationValidator.cs +++ b/Application/Validation/GeoLocationValidator.cs @@ -1,15 +1,10 @@ -using Core.Dto.Response; +using Core.Dto.Request; using Core.Repositories; using FluentValidation; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Application.Validation { - public class GeoLocationValidator : AbstractValidator + public class GeoLocationValidator : AbstractValidator { private readonly IGeoLocationsRepository _geoLocationsRepository; diff --git a/Core/Dto/Request/CreateSuggestedNameDto.cs b/Core/Dto/Request/CreateSuggestedNameDto.cs index 4d9b021..de0cb9c 100644 --- a/Core/Dto/Request/CreateSuggestedNameDto.cs +++ b/Core/Dto/Request/CreateSuggestedNameDto.cs @@ -1,17 +1,14 @@ - -using Core.Dto.Response; - -namespace Core.Dto.Request; +namespace Core.Dto.Request; public record CreateSuggestedNameDto { public string Name { get; init; } public string Details { get; init; } public string Email { get; init; } - public List GeoLocation { get; set; } + public List GeoLocation { get; set; } public CreateSuggestedNameDto() { - GeoLocation = new List(); + GeoLocation = new List(); } } diff --git a/Core/Dto/Request/NameDto.cs b/Core/Dto/Request/NameDto.cs index 6df72c4..beb674e 100644 --- a/Core/Dto/Request/NameDto.cs +++ b/Core/Dto/Request/NameDto.cs @@ -1,6 +1,4 @@ -using Core.Dto.Response; -using Core.Enums; -using System.ComponentModel.DataAnnotations; +using Core.Enums; namespace Core.Dto.Request { @@ -14,7 +12,7 @@ public abstract class NameDto public State? State { get; set; } public List Etymology { get; set; } public List Videos { get; set; } - public List GeoLocation { get; set; } + public List GeoLocation { get; set; } public CommaSeparatedString? FamousPeople { get; set; } @@ -34,14 +32,14 @@ public NameDto(string name, string meaning) Meaning = meaning ?? throw new ArgumentNullException(nameof(meaning)); Etymology = new List(); Videos = new List(); - GeoLocation = new List(); + GeoLocation = new List(); } public NameDto() { Etymology = new List(); Videos = new List(); - GeoLocation = new List(); + GeoLocation = new List(); } } } From b2a495312c0dc87113d5cb1680f9e737c5bafc52 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Sat, 17 Aug 2024 09:40:03 +0300 Subject: [PATCH 05/20] Trim string fields during conversion of request DTOs into entities. (#93) --- Api/Controllers/SuggestedNameController.cs | 5 +++-- Application/Mappers/NameEntryMapper.cs | 2 +- Application/Mappers/SuggestedNameMapper.cs | 6 +++--- Application/Validation/NameValidator.cs | 6 +----- Core/Dto/CharacterSeparatedString.cs | 2 +- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Api/Controllers/SuggestedNameController.cs b/Api/Controllers/SuggestedNameController.cs index f3271f8..ffb56af 100644 --- a/Api/Controllers/SuggestedNameController.cs +++ b/Api/Controllers/SuggestedNameController.cs @@ -7,6 +7,7 @@ using System.Net; using Application.Mappers; using Application.Validation; +using FluentValidation; namespace Api.Controllers; @@ -16,9 +17,9 @@ namespace Api.Controllers; public class SuggestedNameController : ControllerBase { private readonly SuggestedNameService _suggestedNameService; - private readonly CreateSuggestedNameValidator _suggestedNameValidator; + private readonly IValidator _suggestedNameValidator; - public SuggestedNameController(SuggestedNameService suggestedNameService, CreateSuggestedNameValidator suggestedNameValidator) + public SuggestedNameController(SuggestedNameService suggestedNameService, IValidator suggestedNameValidator) { _suggestedNameService = suggestedNameService; _suggestedNameValidator = suggestedNameValidator; diff --git a/Application/Mappers/NameEntryMapper.cs b/Application/Mappers/NameEntryMapper.cs index 36b6b92..57f9200 100644 --- a/Application/Mappers/NameEntryMapper.cs +++ b/Application/Mappers/NameEntryMapper.cs @@ -29,7 +29,7 @@ public static NameEntry MapToEntity(this NameDto request) { return new NameEntry { - Name = request.Name, + Name = request.Name.Trim(), Pronunciation = request.Pronunciation?.Trim(), Meaning = request.Meaning.Trim(), ExtendedMeaning = request.ExtendedMeaning?.Trim(), diff --git a/Application/Mappers/SuggestedNameMapper.cs b/Application/Mappers/SuggestedNameMapper.cs index b09e2fa..b26dc52 100644 --- a/Application/Mappers/SuggestedNameMapper.cs +++ b/Application/Mappers/SuggestedNameMapper.cs @@ -16,9 +16,9 @@ public static SuggestedName MapToEntity(this CreateSuggestedNameDto request) return new SuggestedName { Id = ObjectId.GenerateNewId().ToString(), - Name = request.Name, - Email = request.Email, - Details = request.Details, + Name = request.Name.Trim(), + Email = request.Email?.Trim(), + Details = request.Details?.Trim(), GeoLocation = request.GeoLocation.Select(x => new GeoLocation { Place = x.Place, diff --git a/Application/Validation/NameValidator.cs b/Application/Validation/NameValidator.cs index 83b27f3..c6b0a52 100644 --- a/Application/Validation/NameValidator.cs +++ b/Application/Validation/NameValidator.cs @@ -1,13 +1,9 @@ -using Core.Dto; -using Core.Dto.Request; +using Core.Dto.Request; using FluentValidation; namespace Application.Validation { public class NameValidator : AbstractValidator { - - - public NameValidator(GeoLocationValidator geoLocationValidator, EmbeddedVideoValidator embeddedVideoValidator, EtymologyValidator etymologyValidator) { RuleLevelCascadeMode = CascadeMode.Stop; diff --git a/Core/Dto/CharacterSeparatedString.cs b/Core/Dto/CharacterSeparatedString.cs index 1f5b5da..57eb98b 100644 --- a/Core/Dto/CharacterSeparatedString.cs +++ b/Core/Dto/CharacterSeparatedString.cs @@ -8,7 +8,7 @@ public abstract class CharacterSeparatedString where T : CharacterSeparatedSt public CharacterSeparatedString(string? value) { - this.value = value ?? string.Empty; + this.value = value?.Trim() ?? string.Empty; } public override string ToString() From 9c9d44202dfe5d6c646716dec67b43bd94f69c3d Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Fri, 23 Aug 2024 16:54:49 +0300 Subject: [PATCH 06/20] Feature/publish to twitter (#95) * Make event handler names more specific. * Post to Twitter when a word is published. * Finalize twitter integration. * Fix file name to match class name. --- Api/Api.csproj | 10 +-- Api/Dockerfile | 4 +- Api/Program.cs | 31 +++++-- Api/appsettings.json | 11 ++- Application/Application.csproj | 6 ++ .../DeletedNameCachingHandler.cs | 38 ++++++++ .../EventHandlers/NameDeletedEventHandler.cs | 38 -------- .../EventHandlers/NameIndexedEventHandler.cs | 7 +- .../PostPublishedNameCommandHandler.cs | 25 ++++++ .../Events/PostPublishedNameCommand.cs | 8 ++ Application/Services/EventPubService.cs | 3 +- Infrastructure.MongoDB/DependencyInjection.cs | 14 +-- Infrastructure/Configuration/TwitterConfig.cs | 13 +++ Infrastructure/DependencyInjection.cs | 32 +++++++ Infrastructure/Infrastructure.csproj | 20 +++++ Infrastructure/Services/NamePostingService.cs | 74 ++++++++++++++++ Infrastructure/TweetsV2Poster.cs | 87 +++++++++++++++++++ Test/Test.csproj | 58 +++++-------- Website/Dockerfile | 2 - YorubaNameDictionary.sln | 6 ++ docker-compose.override.yml | 4 + 21 files changed, 386 insertions(+), 105 deletions(-) create mode 100644 Application/EventHandlers/DeletedNameCachingHandler.cs delete mode 100644 Application/EventHandlers/NameDeletedEventHandler.cs create mode 100644 Application/EventHandlers/PostPublishedNameCommandHandler.cs create mode 100644 Application/Events/PostPublishedNameCommand.cs create mode 100644 Infrastructure/Configuration/TwitterConfig.cs create mode 100644 Infrastructure/DependencyInjection.cs create mode 100644 Infrastructure/Infrastructure.csproj create mode 100644 Infrastructure/Services/NamePostingService.cs create mode 100644 Infrastructure/TweetsV2Poster.cs diff --git a/Api/Api.csproj b/Api/Api.csproj index 7b562c0..672f7ae 100644 --- a/Api/Api.csproj +++ b/Api/Api.csproj @@ -1,24 +1,20 @@ - - net6.0 + net8.0 enable enable 15373697-caf3-4a5a-b976-46077b7bff45 Linux ..\docker-compose.dcproj - - - + - - + \ No newline at end of file diff --git a/Api/Dockerfile b/Api/Dockerfile index 78e89ad..ba75a10 100644 --- a/Api/Dockerfile +++ b/Api/Dockerfile @@ -1,10 +1,10 @@ #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base WORKDIR /app EXPOSE 80 -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY ["Api/Api.csproj", "Api/"] RUN dotnet restore "Api/Api.csproj" diff --git a/Api/Program.cs b/Api/Program.cs index be5eb25..fcecba0 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -10,14 +10,19 @@ using Core.Events; using Core.StringObjectConverters; using FluentValidation; +using Infrastructure; using Infrastructure.MongoDB; +using Infrastructure.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.OpenApi.Models; using MySqlConnector; +using System.Collections.Concurrent; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); -var Configuration = builder.Configuration; +var configuration = builder.Configuration; +configuration.AddEnvironmentVariables("YND_"); + string DevCORSAllowAll = "AllowAllForDev"; var services = builder.Services; @@ -81,28 +86,36 @@ } }); }); -var mongoDbSettings = Configuration.GetSection("MongoDB"); +var mongoDbSettings = configuration.GetSection("MongoDB"); services.InitializeDatabase(mongoDbSettings.GetValue("ConnectionString"), mongoDbSettings.GetValue("DatabaseName")); builder.Services.AddTransient(x => new MySqlConnection(builder.Configuration.GetSection("MySQL:ConnectionString").Value)); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + +//Validation services.AddValidatorsFromAssemblyContaining(); + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ExactNameSearchedAdapter).Assembly)); +// Twitter integration configuration +services.AddSingleton>(); +services.AddTwitterClient(configuration); +services.AddHostedService(); + var app = builder.Build(); diff --git a/Api/appsettings.json b/Api/appsettings.json index b878027..8773081 100644 --- a/Api/appsettings.json +++ b/Api/appsettings.json @@ -6,6 +6,13 @@ "Application.Services.BasicAuthenticationHandler": "Warning" } }, - "AllowedHosts": "*" - + "AllowedHosts": "*", + "Twitter": { + "AccessToken": "your-access-token", + "AccessTokenSecret": "your-access-token-secret", + "ConsumerKey": "your-consumer-key", + "ConsumerSecret": "your-consumer-secret", + "NameUrlPrefix": "https://www.yorubaname.com/entries", + "TweetTemplate": "New name entry: {name}, {meaning}. More here: {link}" + } } diff --git a/Application/Application.csproj b/Application/Application.csproj index 6a16eb9..b8025e4 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -6,6 +6,12 @@ enable + + + + + + diff --git a/Application/EventHandlers/DeletedNameCachingHandler.cs b/Application/EventHandlers/DeletedNameCachingHandler.cs new file mode 100644 index 0000000..79df525 --- /dev/null +++ b/Application/EventHandlers/DeletedNameCachingHandler.cs @@ -0,0 +1,38 @@ +using Application.Events; +using Core.Cache; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Application.EventHandlers +{ + public class DeletedNameCachingHandler : INotificationHandler + { + private readonly IRecentIndexesCache _recentIndexesCache; + private readonly IRecentSearchesCache _recentSearchesCache; + private readonly ILogger _logger; + + public DeletedNameCachingHandler( + IRecentIndexesCache recentIndexesCache, + IRecentSearchesCache recentSearchesCache, + ILogger logger + ) + { + _recentIndexesCache = recentIndexesCache; + _recentSearchesCache = recentSearchesCache; + _logger = logger; + } + + public async Task Handle(NameDeletedAdapter notification, CancellationToken cancellationToken) + { + try + { + await _recentIndexesCache.Remove(notification.Name); + await _recentSearchesCache.Remove(notification.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while removing deleted name '{name}' from cache.", notification.Name); + } + } + } +} \ No newline at end of file diff --git a/Application/EventHandlers/NameDeletedEventHandler.cs b/Application/EventHandlers/NameDeletedEventHandler.cs deleted file mode 100644 index acc228b..0000000 --- a/Application/EventHandlers/NameDeletedEventHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Application.Events; -using Application.Services; -using Core.Cache; -using MediatR; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Application.EventHandlers -{ - - public class NameDeletedEventHandler : INotificationHandler - { - private IRecentIndexesCache _recentIndexesCache; - private IRecentSearchesCache _recentSearchesCache; - - public NameDeletedEventHandler(IRecentIndexesCache recentIndexesCache, IRecentSearchesCache recentSearchesCache) - { - _recentIndexesCache = recentIndexesCache; - _recentSearchesCache = recentSearchesCache; - } - - public async Task Handle(NameDeletedAdapter notification, CancellationToken cancellationToken) - { - try - { - await _recentIndexesCache.Remove(notification.Name); - await _recentSearchesCache.Remove(notification.Name); - } - catch (Exception ex) - { - //TODO log this - } - } - } -} \ No newline at end of file diff --git a/Application/EventHandlers/NameIndexedEventHandler.cs b/Application/EventHandlers/NameIndexedEventHandler.cs index 6312318..2f83e3a 100644 --- a/Application/EventHandlers/NameIndexedEventHandler.cs +++ b/Application/EventHandlers/NameIndexedEventHandler.cs @@ -7,15 +7,20 @@ namespace Application.EventHandlers public class NameIndexedEventHandler : INotificationHandler { public IRecentIndexesCache _recentIndexesCache; + private readonly IMediator _mediator; - public NameIndexedEventHandler(IRecentIndexesCache recentIndexesCache) + public NameIndexedEventHandler( + IRecentIndexesCache recentIndexesCache, + IMediator mediator) { _recentIndexesCache = recentIndexesCache; + _mediator = mediator; } public async Task Handle(NameIndexedAdapter notification, CancellationToken cancellationToken) { await _recentIndexesCache.Stack(notification.Name); + await _mediator.Publish(new PostPublishedNameCommand(notification.Name), cancellationToken); } } } diff --git a/Application/EventHandlers/PostPublishedNameCommandHandler.cs b/Application/EventHandlers/PostPublishedNameCommandHandler.cs new file mode 100644 index 0000000..7f8b3e5 --- /dev/null +++ b/Application/EventHandlers/PostPublishedNameCommandHandler.cs @@ -0,0 +1,25 @@ +using Application.Events; +using MediatR; +using System.Collections.Concurrent; + +namespace Application.EventHandlers +{ + public class PostPublishedNameCommandHandler : INotificationHandler + { + private readonly ConcurrentQueue _nameQueue; + + public PostPublishedNameCommandHandler(ConcurrentQueue nameQueue) + { + _nameQueue = nameQueue; + } + + public Task Handle(PostPublishedNameCommand notification, CancellationToken cancellationToken) + { + // Enqueue the indexed name for processing by the BackgroundService + _nameQueue.Enqueue(notification); + + // Return a completed task, so it doesn't block the main thread + return Task.CompletedTask; + } + } +} diff --git a/Application/Events/PostPublishedNameCommand.cs b/Application/Events/PostPublishedNameCommand.cs new file mode 100644 index 0000000..62f21d7 --- /dev/null +++ b/Application/Events/PostPublishedNameCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace Application.Events +{ + public record PostPublishedNameCommand(string Name) : INotification + { + } +} diff --git a/Application/Services/EventPubService.cs b/Application/Services/EventPubService.cs index c101410..f97828c 100644 --- a/Application/Services/EventPubService.cs +++ b/Application/Services/EventPubService.cs @@ -13,7 +13,6 @@ public EventPubService(IMediator mediator) _mediator = mediator; } - public async Task PublishEvent(T theEvent) { var adapterClassName = typeof(T).Name + "Adapter"; @@ -24,7 +23,7 @@ public async Task PublishEvent(T theEvent) throw new InvalidOperationException("Adapter type not found for " + typeof(T).FullName); } - var adapterEvent = Activator.CreateInstance(adapterType, theEvent); + var adapterEvent = Activator.CreateInstance(adapterType, theEvent)!; await _mediator.Publish(adapterEvent); } } diff --git a/Infrastructure.MongoDB/DependencyInjection.cs b/Infrastructure.MongoDB/DependencyInjection.cs index f118bf9..58b8204 100644 --- a/Infrastructure.MongoDB/DependencyInjection.cs +++ b/Infrastructure.MongoDB/DependencyInjection.cs @@ -10,14 +10,14 @@ public static class DependencyInjection public static void InitializeDatabase(this IServiceCollection services, string connectionString, string databaseName) { services.AddSingleton(s => new MongoClient(connectionString)); - services.AddScoped(s => s.GetRequiredService().GetDatabase(databaseName)); + services.AddSingleton(s => s.GetRequiredService().GetDatabase(databaseName)); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } } } diff --git a/Infrastructure/Configuration/TwitterConfig.cs b/Infrastructure/Configuration/TwitterConfig.cs new file mode 100644 index 0000000..cea16ec --- /dev/null +++ b/Infrastructure/Configuration/TwitterConfig.cs @@ -0,0 +1,13 @@ +namespace Infrastructure.Configuration +{ + public record TwitterConfig( + string ConsumerKey, + string ConsumerSecret, + string AccessToken, + string AccessTokenSecret, + string NameUrlPrefix, + string TweetTemplate) + { + public TwitterConfig() : this("", "", "", "", "", "") { } + } +} diff --git a/Infrastructure/DependencyInjection.cs b/Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..032deea --- /dev/null +++ b/Infrastructure/DependencyInjection.cs @@ -0,0 +1,32 @@ +using Infrastructure.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Tweetinvi; + +namespace Infrastructure +{ + public static class DependencyInjection + { + private const string ConfigSectionName = "Twitter"; + + public static IServiceCollection AddTwitterClient(this IServiceCollection services, IConfiguration configuration) + { + var config = configuration.GetRequiredSection(ConfigSectionName); + + services.Configure(config); + + services.AddSingleton(provider => + { + var twitterConfig = config.Get()!; + return new TwitterClient( + twitterConfig.ConsumerKey, + twitterConfig.ConsumerSecret, + twitterConfig.AccessToken, + twitterConfig.AccessTokenSecret + ); + }); + + return services; + } + } +} diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj new file mode 100644 index 0000000..b2ef52c --- /dev/null +++ b/Infrastructure/Infrastructure.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/Infrastructure/Services/NamePostingService.cs b/Infrastructure/Services/NamePostingService.cs new file mode 100644 index 0000000..9d4b95f --- /dev/null +++ b/Infrastructure/Services/NamePostingService.cs @@ -0,0 +1,74 @@ +using Application.Domain; +using Application.Events; +using Infrastructure.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Collections.Concurrent; +using Tweetinvi; + +namespace Infrastructure.Services +{ + public class NamePostingService( + ConcurrentQueue nameQueue, + ITwitterClient twitterApiClient, + ILogger logger, + NameEntryService nameEntryService, + IOptions twitterConfig) : BackgroundService + { + private const string TweetComposeFailure = "Failed to build tweet for name: {name}. It was not found in the database."; + private readonly ConcurrentQueue _nameQueue = nameQueue; + private readonly TweetsV2Poster _twitterApiClient = new (twitterApiClient); + private readonly ILogger _logger = logger; + private readonly NameEntryService _nameEntryService = nameEntryService; + private readonly TwitterConfig _twitterConfig = twitterConfig.Value; + private const int TweetIntervalMs = 3 * 60 * 1000; // 3 minutes + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (!_nameQueue.TryDequeue(out var indexedName)) + { + await Task.Delay(TweetIntervalMs, stoppingToken); + continue; + } + + string? tweetText = await BuildTweet(indexedName.Name); + + if (string.IsNullOrWhiteSpace(tweetText)) + { + _logger.LogWarning(TweetComposeFailure, indexedName.Name); + continue; + } + + try + { + var tweet = await _twitterApiClient.PostTweet(tweetText); + if (tweet != null) + { + _logger.LogInformation("Tweeted name: {name} successfully with ID: {tweetId}", indexedName.Name, tweet.Id); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to tweet name: {name} to Twitter.", indexedName.Name); + _nameQueue.Enqueue(indexedName); + } + + await Task.Delay(TweetIntervalMs, stoppingToken); + } + } + + private async Task BuildTweet(string name) + { + string link = $"{_twitterConfig.NameUrlPrefix}/{name}"; + var nameEntry = await _nameEntryService.LoadName(name); + return nameEntry == null ? null : _twitterConfig.TweetTemplate + .Replace("{name}", nameEntry.Name) + .Replace("{meaning}", nameEntry.Meaning.TrimEnd('.')) + .Replace("{link}", link); + } + + } +} diff --git a/Infrastructure/TweetsV2Poster.cs b/Infrastructure/TweetsV2Poster.cs new file mode 100644 index 0000000..7d6637a --- /dev/null +++ b/Infrastructure/TweetsV2Poster.cs @@ -0,0 +1,87 @@ +using System.Text; +using Tweetinvi.Core.Web; +using Tweetinvi.Models; +using Tweetinvi; +using Newtonsoft.Json; + +namespace Infrastructure +{ + public class TweetsV2Poster + { + // ----------------- Fields ---------------- + + private readonly ITwitterClient _client; + + // ----------------- Constructor ---------------- + + public TweetsV2Poster(ITwitterClient client) + { + _client = client; + } + + public async Task PostTweet(string text) + { + var result = await _client.Execute.AdvanceRequestAsync( + (ITwitterRequest request) => + { + var jsonBody = _client.Json.Serialize(new TweetV2PostRequest + { + Text = text + }); + + var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + + request.Query.Url = "https://api.twitter.com/2/tweets"; + request.Query.HttpMethod = Tweetinvi.Models.HttpMethod.POST; + request.Query.HttpContent = content; + } + ); + + if (!result.Response.IsSuccessStatusCode) + { + throw new Exception($"Error when posting tweet:{Environment.NewLine}{result.Content}"); + } + + return _client.Json.Deserialize(result.Content); + } + + /// + /// There are a lot more fields according to: + /// https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/post-tweets + /// but these are the ones we care about for our use case. + /// + private class TweetV2PostRequest + { + [JsonProperty("text")] + public string Text { get; set; } = string.Empty; + } + } + + public record TweetV2PostData + { + [JsonProperty("id")] + public string Id { get; init; } + [JsonProperty("text")] + public string Text { get; init; } + + public TweetV2PostData(string id, string text) + { + Id = id; + Text = text; + } + } + + public record TweetV2PostResponse + { + [JsonProperty("data")] + public TweetV2PostData Data { get; init; } + + public string? Id => Data?.Id; + public string? Text => Data?.Text; + + public TweetV2PostResponse(TweetV2PostData data) + { + Data = data; + } + } +} diff --git a/Test/Test.csproj b/Test/Test.csproj index b319d04..711091c 100644 --- a/Test/Test.csproj +++ b/Test/Test.csproj @@ -1,36 +1,24 @@ - - - net6.0 - enable - - false - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - + + net8.0 + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + \ No newline at end of file diff --git a/Website/Dockerfile b/Website/Dockerfile index 78fb695..03cf50f 100644 --- a/Website/Dockerfile +++ b/Website/Dockerfile @@ -10,8 +10,6 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Website/Website.csproj", "Website/"] -COPY ["Application/Application.csproj", "Application/"] -COPY ["Core/Core.csproj", "Core/"] RUN dotnet restore "./Website/Website.csproj" COPY . . WORKDIR "/src/Website" diff --git a/YorubaNameDictionary.sln b/YorubaNameDictionary.sln index 4da7efc..6bbaa85 100644 --- a/YorubaNameDictionary.sln +++ b/YorubaNameDictionary.sln @@ -27,6 +27,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{78930A71-3759-46B3-9429-80C4D4933D12}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -61,6 +63,10 @@ Global {A69D2C16-E0AD-4911-8D4F-42369452BAB2}.Debug|Any CPU.Build.0 = Debug|Any CPU {A69D2C16-E0AD-4911-8D4F-42369452BAB2}.Release|Any CPU.ActiveCfg = Release|Any CPU {A69D2C16-E0AD-4911-8D4F-42369452BAB2}.Release|Any CPU.Build.0 = Release|Any CPU + {78930A71-3759-46B3-9429-80C4D4933D12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {78930A71-3759-46B3-9429-80C4D4933D12}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78930A71-3759-46B3-9429-80C4D4933D12}.Release|Any CPU.ActiveCfg = Release|Any CPU + {78930A71-3759-46B3-9429-80C4D4933D12}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 6f9b93d..b594ccd 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -14,6 +14,10 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://+:80 + - Twitter__ConsumerKey=${YND_Twitter__ConsumerKey} + - Twitter__ConsumerSecret=${YND_Twitter__ConsumerSecret} + - Twitter__AccessToken=${YND_Twitter__AccessToken} + - Twitter__AccessTokenSecret=${YND_Twitter__AccessTokenSecret} ports: - "51515:80" volumes: From 709ebb21d57c18d710a70b232cb1b105ef2dae5d Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Fri, 23 Aug 2024 17:03:02 +0300 Subject: [PATCH 07/20] Upgrade deployment workflow for the API to.NET 8 on all environments. (#96) --- .github/workflows/api-deploy-PROD.yml | 2 +- .github/workflows/api-deploy-staging.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/api-deploy-PROD.yml b/.github/workflows/api-deploy-PROD.yml index 06d7ba5..30152bd 100644 --- a/.github/workflows/api-deploy-PROD.yml +++ b/.github/workflows/api-deploy-PROD.yml @@ -23,7 +23,7 @@ jobs: secrets: inherit with: project-name: Api - dotnet-version: '6.0.x' + dotnet-version: '8.0.x' service-name: ${{ needs.fetch-vars.outputs.service_name }} service-path: ${{ needs.fetch-vars.outputs.service_path }} environment: PROD diff --git a/.github/workflows/api-deploy-staging.yml b/.github/workflows/api-deploy-staging.yml index 1448cf8..8f366a3 100644 --- a/.github/workflows/api-deploy-staging.yml +++ b/.github/workflows/api-deploy-staging.yml @@ -23,7 +23,7 @@ jobs: secrets: inherit with: project-name: Api - dotnet-version: '6.0.x' + dotnet-version: '8.0.x' service-name: ${{ needs.fetch-vars.outputs.service_name }} service-path: ${{ needs.fetch-vars.outputs.service_path }} environment: staging From 219ad16b85f39dcb9b788041dc496048c2ebd654 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Mon, 26 Aug 2024 11:41:29 +0300 Subject: [PATCH 08/20] Maintenance/ivw prep fixes (#98) * Refactor TwitterClient; Make Tweet Delay configurable; Change Tweet template on Dev * Fix integration tests. * Use FluentAssertions and use AutoFixture for data generation * Create repository instance only once and not in each test. * Use AutoFixture for all test data generation. * Create a separate AppConfig file for the integration tests. --- Api/appsettings.Development.json | 4 + Api/appsettings.Test.json | 6 + Api/appsettings.json | 3 +- .../JsonSerializerOptionsProvider.cs | 5 +- Infrastructure/Configuration/TwitterConfig.cs | 5 +- Infrastructure/DependencyInjection.cs | 3 + Infrastructure/Services/NamePostingService.cs | 12 +- Infrastructure/TweetsV2Poster.cs | 87 ---- Infrastructure/Twitter/ITwitterClientV2.cs | 7 + Infrastructure/Twitter/TweetV2PostData.cs | 17 + Infrastructure/Twitter/TweetV2PostResponse.cs | 17 + Infrastructure/Twitter/TwitterClientV2.cs | 52 +++ Test/BootStrappedApiFactory.cs | 33 +- .../NameController/Data/NamesAllTestData.cs | 58 +-- .../Data/NamesCountAndSubmittedByTestData.cs | 90 ++-- .../NameController/Data/NamesCountTestData.cs | 82 ++-- .../Data/NamesStateAndCountTestData.cs | 58 +-- .../Data/NamesStateAndSubmittedByTestData.cs | 75 ++-- .../NamesStateCountAndSubmittedByTestData.cs | 74 ++-- .../NameController/Data/NamesStateTestData.cs | 66 +-- .../Data/NamesSubmittedByTestData.cs | 74 ++-- .../NameController/NameControllerTest.cs | 399 +++++++++--------- Test/Test.csproj | 2 + Website/Program.cs | 2 +- 24 files changed, 524 insertions(+), 707 deletions(-) create mode 100644 Api/appsettings.Test.json rename {Website/Utilities => Core/StringObjectConverters}/JsonSerializerOptionsProvider.cs (85%) delete mode 100644 Infrastructure/TweetsV2Poster.cs create mode 100644 Infrastructure/Twitter/ITwitterClientV2.cs create mode 100644 Infrastructure/Twitter/TweetV2PostData.cs create mode 100644 Infrastructure/Twitter/TweetV2PostResponse.cs create mode 100644 Infrastructure/Twitter/TwitterClientV2.cs diff --git a/Api/appsettings.Development.json b/Api/appsettings.Development.json index f5c807c..6ae5b70 100644 --- a/Api/appsettings.Development.json +++ b/Api/appsettings.Development.json @@ -11,5 +11,9 @@ }, "MySQL": { "ConnectionString": "Server=localhost;User ID=root;Password=SweetFresh06;Database=dictionary" + }, + "Twitter": { + "TweetTemplate": "{name}: \"{meaning}\" {link}", + "TweetIntervalSeconds": 5 } } diff --git a/Api/appsettings.Test.json b/Api/appsettings.Test.json new file mode 100644 index 0000000..4dbdf1f --- /dev/null +++ b/Api/appsettings.Test.json @@ -0,0 +1,6 @@ +{ + "Twitter": { + "TweetTemplate": "{name}: \"{meaning}\" {link}", + "TweetIntervalSeconds": 0.1 + } +} diff --git a/Api/appsettings.json b/Api/appsettings.json index 8773081..07c914b 100644 --- a/Api/appsettings.json +++ b/Api/appsettings.json @@ -13,6 +13,7 @@ "ConsumerKey": "your-consumer-key", "ConsumerSecret": "your-consumer-secret", "NameUrlPrefix": "https://www.yorubaname.com/entries", - "TweetTemplate": "New name entry: {name}, {meaning}. More here: {link}" + "TweetTemplate": "New name entry: {name}, {meaning}. More here: {link}", + "TweetIntervalSeconds": 180 } } diff --git a/Website/Utilities/JsonSerializerOptionsProvider.cs b/Core/StringObjectConverters/JsonSerializerOptionsProvider.cs similarity index 85% rename from Website/Utilities/JsonSerializerOptionsProvider.cs rename to Core/StringObjectConverters/JsonSerializerOptionsProvider.cs index ed518b9..8ea7090 100644 --- a/Website/Utilities/JsonSerializerOptionsProvider.cs +++ b/Core/StringObjectConverters/JsonSerializerOptionsProvider.cs @@ -1,8 +1,7 @@ -using Core.StringObjectConverters; -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; -namespace Website.Utilities +namespace Core.StringObjectConverters { public static class JsonSerializerOptionsProvider { diff --git a/Infrastructure/Configuration/TwitterConfig.cs b/Infrastructure/Configuration/TwitterConfig.cs index cea16ec..3c814e7 100644 --- a/Infrastructure/Configuration/TwitterConfig.cs +++ b/Infrastructure/Configuration/TwitterConfig.cs @@ -6,8 +6,9 @@ public record TwitterConfig( string AccessToken, string AccessTokenSecret, string NameUrlPrefix, - string TweetTemplate) + string TweetTemplate, + decimal TweetIntervalSeconds) { - public TwitterConfig() : this("", "", "", "", "", "") { } + public TwitterConfig() : this("", "", "", "", "", "", default) { } } } diff --git a/Infrastructure/DependencyInjection.cs b/Infrastructure/DependencyInjection.cs index 032deea..37b6713 100644 --- a/Infrastructure/DependencyInjection.cs +++ b/Infrastructure/DependencyInjection.cs @@ -1,4 +1,5 @@ using Infrastructure.Configuration; +using Infrastructure.Twitter; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Tweetinvi; @@ -26,6 +27,8 @@ public static IServiceCollection AddTwitterClient(this IServiceCollection servic ); }); + services.AddSingleton(); + return services; } } diff --git a/Infrastructure/Services/NamePostingService.cs b/Infrastructure/Services/NamePostingService.cs index 9d4b95f..54274d0 100644 --- a/Infrastructure/Services/NamePostingService.cs +++ b/Infrastructure/Services/NamePostingService.cs @@ -1,36 +1,36 @@ using Application.Domain; using Application.Events; using Infrastructure.Configuration; +using Infrastructure.Twitter; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Collections.Concurrent; -using Tweetinvi; namespace Infrastructure.Services { public class NamePostingService( ConcurrentQueue nameQueue, - ITwitterClient twitterApiClient, + ITwitterClientV2 twitterApiClient, ILogger logger, NameEntryService nameEntryService, IOptions twitterConfig) : BackgroundService { private const string TweetComposeFailure = "Failed to build tweet for name: {name}. It was not found in the database."; private readonly ConcurrentQueue _nameQueue = nameQueue; - private readonly TweetsV2Poster _twitterApiClient = new (twitterApiClient); + private readonly ITwitterClientV2 _twitterApiClient = twitterApiClient; private readonly ILogger _logger = logger; private readonly NameEntryService _nameEntryService = nameEntryService; private readonly TwitterConfig _twitterConfig = twitterConfig.Value; - private const int TweetIntervalMs = 3 * 60 * 1000; // 3 minutes protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + var tweetIntervalMs = (int)(_twitterConfig.TweetIntervalSeconds * 1000); while (!stoppingToken.IsCancellationRequested) { if (!_nameQueue.TryDequeue(out var indexedName)) { - await Task.Delay(TweetIntervalMs, stoppingToken); + await Task.Delay(tweetIntervalMs, stoppingToken); continue; } @@ -56,7 +56,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _nameQueue.Enqueue(indexedName); } - await Task.Delay(TweetIntervalMs, stoppingToken); + await Task.Delay(tweetIntervalMs, stoppingToken); } } diff --git a/Infrastructure/TweetsV2Poster.cs b/Infrastructure/TweetsV2Poster.cs deleted file mode 100644 index 7d6637a..0000000 --- a/Infrastructure/TweetsV2Poster.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Text; -using Tweetinvi.Core.Web; -using Tweetinvi.Models; -using Tweetinvi; -using Newtonsoft.Json; - -namespace Infrastructure -{ - public class TweetsV2Poster - { - // ----------------- Fields ---------------- - - private readonly ITwitterClient _client; - - // ----------------- Constructor ---------------- - - public TweetsV2Poster(ITwitterClient client) - { - _client = client; - } - - public async Task PostTweet(string text) - { - var result = await _client.Execute.AdvanceRequestAsync( - (ITwitterRequest request) => - { - var jsonBody = _client.Json.Serialize(new TweetV2PostRequest - { - Text = text - }); - - var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); - - request.Query.Url = "https://api.twitter.com/2/tweets"; - request.Query.HttpMethod = Tweetinvi.Models.HttpMethod.POST; - request.Query.HttpContent = content; - } - ); - - if (!result.Response.IsSuccessStatusCode) - { - throw new Exception($"Error when posting tweet:{Environment.NewLine}{result.Content}"); - } - - return _client.Json.Deserialize(result.Content); - } - - /// - /// There are a lot more fields according to: - /// https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/post-tweets - /// but these are the ones we care about for our use case. - /// - private class TweetV2PostRequest - { - [JsonProperty("text")] - public string Text { get; set; } = string.Empty; - } - } - - public record TweetV2PostData - { - [JsonProperty("id")] - public string Id { get; init; } - [JsonProperty("text")] - public string Text { get; init; } - - public TweetV2PostData(string id, string text) - { - Id = id; - Text = text; - } - } - - public record TweetV2PostResponse - { - [JsonProperty("data")] - public TweetV2PostData Data { get; init; } - - public string? Id => Data?.Id; - public string? Text => Data?.Text; - - public TweetV2PostResponse(TweetV2PostData data) - { - Data = data; - } - } -} diff --git a/Infrastructure/Twitter/ITwitterClientV2.cs b/Infrastructure/Twitter/ITwitterClientV2.cs new file mode 100644 index 0000000..ee4d96f --- /dev/null +++ b/Infrastructure/Twitter/ITwitterClientV2.cs @@ -0,0 +1,7 @@ +namespace Infrastructure.Twitter +{ + public interface ITwitterClientV2 + { + Task PostTweet(string text); + } +} diff --git a/Infrastructure/Twitter/TweetV2PostData.cs b/Infrastructure/Twitter/TweetV2PostData.cs new file mode 100644 index 0000000..b6f6694 --- /dev/null +++ b/Infrastructure/Twitter/TweetV2PostData.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +namespace Infrastructure.Twitter +{ + public record TweetV2PostData + { + [JsonProperty("id")] + public string Id { get; init; } + [JsonProperty("text")] + public string Text { get; init; } + + public TweetV2PostData(string id, string text) + { + Id = id; + Text = text; + } + } +} diff --git a/Infrastructure/Twitter/TweetV2PostResponse.cs b/Infrastructure/Twitter/TweetV2PostResponse.cs new file mode 100644 index 0000000..0a0b51f --- /dev/null +++ b/Infrastructure/Twitter/TweetV2PostResponse.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +namespace Infrastructure.Twitter +{ + public record TweetV2PostResponse + { + [JsonProperty("data")] + public TweetV2PostData Data { get; init; } + + public string? Id => Data?.Id; + public string? Text => Data?.Text; + + public TweetV2PostResponse(TweetV2PostData data) + { + Data = data; + } + } +} diff --git a/Infrastructure/Twitter/TwitterClientV2.cs b/Infrastructure/Twitter/TwitterClientV2.cs new file mode 100644 index 0000000..453ed3a --- /dev/null +++ b/Infrastructure/Twitter/TwitterClientV2.cs @@ -0,0 +1,52 @@ +using System.Text; +using Tweetinvi; +using Newtonsoft.Json; +namespace Infrastructure.Twitter +{ + public class TwitterClientV2 : ITwitterClientV2 + { + private readonly ITwitterClient _twitterV1Client; + + public TwitterClientV2(ITwitterClient twitterClient) + { + _twitterV1Client = twitterClient; + } + + public async Task PostTweet(string text) + { + var result = await _twitterV1Client.Execute.AdvanceRequestAsync( + (request) => + { + var jsonBody = _twitterV1Client.Json.Serialize(new TweetV2PostRequest + { + Text = text + }); + + var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + + request.Query.Url = "https://api.twitter.com/2/tweets"; + request.Query.HttpMethod = Tweetinvi.Models.HttpMethod.POST; + request.Query.HttpContent = content; + } + ); + + if (!result.Response.IsSuccessStatusCode) + { + throw new Exception($"Error when posting tweet:{Environment.NewLine}{result.Content}"); + } + + return _twitterV1Client.Json.Deserialize(result.Content); + } + + /// + /// There are a lot more fields according to: + /// https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/post-tweets + /// but these are the ones we care about for our use case. + /// + private class TweetV2PostRequest + { + [JsonProperty("text")] + public string Text { get; set; } = string.Empty; + } + } +} diff --git a/Test/BootStrappedApiFactory.cs b/Test/BootStrappedApiFactory.cs index a489588..d2dfdb1 100644 --- a/Test/BootStrappedApiFactory.cs +++ b/Test/BootStrappedApiFactory.cs @@ -1,32 +1,43 @@ using System.Collections.Generic; using System.Net.Http; +using System.Text.Json; using System.Threading.Tasks; using Api; using Core.Repositories; +using Core.StringObjectConverters; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; using Infrastructure.MongoDB.Repositories; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using MongoDB.Driver; using Xunit; namespace Test; -public abstract class BootStrappedApiFactory : WebApplicationFactory , IAsyncLifetime +public class BootStrappedApiFactory : WebApplicationFactory, IAsyncLifetime { private const int HostPort = 27018; private const int ContainerPort = 27017; private const string MongoDbDatabaseName = "yoruba_names_dictionary_test_DB"; private const string MongoDbPassword = "password"; private const string MongoDbUsername = "admin"; + public HttpClient HttpClient { get; private set; } = default!; - + + public JsonSerializerOptions JsonSerializerOptions { get; init; } + + public BootStrappedApiFactory() + { + JsonSerializerOptions = JsonSerializerOptionsProvider.GetJsonSerializerOptionsWithCustomConverters(); + } + private readonly IContainer _testDbContainer = new ContainerBuilder() .WithImage("mongo:latest") - .WithEnvironment( new Dictionary + .WithEnvironment(new Dictionary { {"MONGO_INITDB_ROOT_USERNAME", MongoDbUsername}, {"MONGO_INITDB_ROOT_PASSWORD", MongoDbPassword}, @@ -35,14 +46,22 @@ public abstract class BootStrappedApiFactory : WebApplicationFactory + { + config.Sources.Clear(); + config.AddJsonFile("appSettings.Test.json", optional: false, reloadOnChange: true); + }); + + builder.UseEnvironment("Test"); + builder.ConfigureTestServices(x => { - x.AddSingleton(s => new MongoClient( $"mongodb://{MongoDbUsername}:{MongoDbPassword}@localhost:{HostPort}")); - x.AddScoped(s => s.GetRequiredService().GetDatabase(MongoDbDatabaseName)); - x.AddScoped(); + x.AddSingleton(s => new MongoClient($"mongodb://{MongoDbUsername}:{MongoDbPassword}@localhost:{HostPort}")); + x.AddSingleton(s => s.GetRequiredService().GetDatabase(MongoDbDatabaseName)); + x.AddSingleton(); }); } diff --git a/Test/Integration/NameController/Data/NamesAllTestData.cs b/Test/Integration/NameController/Data/NamesAllTestData.cs index fe40cca..0e87dbf 100644 --- a/Test/Integration/NameController/Data/NamesAllTestData.cs +++ b/Test/Integration/NameController/Data/NamesAllTestData.cs @@ -1,8 +1,8 @@ using System.Collections; using System.Collections.Generic; -using Core.Entities; +using System.Linq; +using AutoFixture; using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; using Core.Enums; namespace Test.Integration.NameController.Data; @@ -19,50 +19,16 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } - private List NameEntries() + private static List NameEntries() { - return new List - { - new NameEntry - { - Name = "Ibironke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - }, - new NameEntry - { - Name = "Aderonke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - } - }; + var fixture = new Fixture(); + + fixture.Customize(c => c + .With(x => x.State, State.PUBLISHED) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + ); + + return fixture.CreateMany(2).ToList(); } } \ No newline at end of file diff --git a/Test/Integration/NameController/Data/NamesCountAndSubmittedByTestData.cs b/Test/Integration/NameController/Data/NamesCountAndSubmittedByTestData.cs index a2e8c88..e3ea1b4 100644 --- a/Test/Integration/NameController/Data/NamesCountAndSubmittedByTestData.cs +++ b/Test/Integration/NameController/Data/NamesCountAndSubmittedByTestData.cs @@ -1,71 +1,41 @@ using System.Collections; using System.Collections.Generic; -using Core.Entities; +using AutoFixture; using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; -using Core.Enums; -namespace Test.Integration.NameController.Data; - -public class NamesCountAndSubmittedByTestData : IEnumerable +namespace Test.Integration.NameController.Data { - public IEnumerator GetEnumerator() + public class NamesCountAndSubmittedByTestData : IEnumerable { - yield return new object[] {NameEntries(), 4, "Adeshina"}; - yield return new object[] {NameEntries(), 5, "Ismaila"}; - } + private readonly string[] _submittedBy = ["Adeshina", "Ismaila"]; + public IEnumerator GetEnumerator() + { + yield return new object[] { CreateNameEntries(), 4, _submittedBy[0] }; + yield return new object[] { CreateNameEntries(), 5, _submittedBy[1] }; + } - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } - private List NameEntries() - { - return new List + private List CreateNameEntries() { - new NameEntry - { - Name = "Ibironke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - CreatedBy = "Adeshina", - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - }, - new NameEntry - { - Name = "Aderonke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - CreatedBy = "Ismaila", - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - } - }; + var fixture = new Fixture(); + + var nameEntry1 = fixture.Build() + .With(ne => ne.CreatedBy, _submittedBy[0]) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + .Create(); + + var nameEntry2 = fixture.Build() + .With(ne => ne.CreatedBy, _submittedBy[1]) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + .Create(); + + return new List { nameEntry1, nameEntry2 }; + } } } \ No newline at end of file diff --git a/Test/Integration/NameController/Data/NamesCountTestData.cs b/Test/Integration/NameController/Data/NamesCountTestData.cs index 6ff51b4..77b1d3b 100644 --- a/Test/Integration/NameController/Data/NamesCountTestData.cs +++ b/Test/Integration/NameController/Data/NamesCountTestData.cs @@ -1,20 +1,34 @@ using System.Collections; using System.Collections.Generic; -using Core.Entities; +using System.Linq; +using AutoFixture; using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; using Core.Enums; - namespace Test.Integration.NameController.Data; public class NamesCountTestData : IEnumerable { + private readonly IFixture _fixture; + private readonly List _nameSequence = ["Ibironke", "Aderonke", "Olumide", "Akinwale", "Oluwaseun"]; + private int _nameIndex = 0; + + public NamesCountTestData() + { + _fixture = new Fixture(); + + _fixture.Customize(c => c + .With(ne => ne.State, State.PUBLISHED) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + .Do(ne => ne.Name = GetNextName())); + } + public IEnumerator GetEnumerator() { - yield return new object[] {NameEntries(), 2 }; - yield return new object[] {NameEntries(), 4 }; - yield return new object[] {NameEntries(), 3 }; - yield return new object[] {NameEntries(), 5 }; + yield return new object[] { CreateNameEntries(5), 2 }; + yield return new object[] { CreateNameEntries(5), 4 }; + yield return new object[] { CreateNameEntries(5), 3 }; + yield return new object[] { CreateNameEntries(5), 5 }; } IEnumerator IEnumerable.GetEnumerator() @@ -22,50 +36,16 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } - private List NameEntries() + private List CreateNameEntries(int count) + { + // Create a list of NameEntries with the specified count + return _fixture.CreateMany(count).ToList(); + } + + private string GetNextName() { - return new List - { - new NameEntry - { - Name = "Ibironke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - }, - new NameEntry - { - Name = "Aderonke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - } - }; + var name = _nameSequence[_nameIndex % _nameSequence.Count]; + _nameIndex++; + return name; } } \ No newline at end of file diff --git a/Test/Integration/NameController/Data/NamesStateAndCountTestData.cs b/Test/Integration/NameController/Data/NamesStateAndCountTestData.cs index af9f24e..d350431 100644 --- a/Test/Integration/NameController/Data/NamesStateAndCountTestData.cs +++ b/Test/Integration/NameController/Data/NamesStateAndCountTestData.cs @@ -1,8 +1,8 @@ using System.Collections; using System.Collections.Generic; -using Core.Entities; +using System.Linq; +using AutoFixture; using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; using Core.Enums; namespace Test.Integration.NameController.Data; @@ -22,50 +22,16 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } - private List NameEntries() + private static List NameEntries() { - return new List - { - new NameEntry - { - Name = "Ibironke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - }, - new NameEntry - { - Name = "Aderonke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - } - }; + var fixture = new Fixture(); + + fixture.Customize(c => c + .With(x => x.State, State.PUBLISHED) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + ); + + return fixture.CreateMany(2).ToList(); } } \ No newline at end of file diff --git a/Test/Integration/NameController/Data/NamesStateAndSubmittedByTestData.cs b/Test/Integration/NameController/Data/NamesStateAndSubmittedByTestData.cs index fbe78fa..f033572 100644 --- a/Test/Integration/NameController/Data/NamesStateAndSubmittedByTestData.cs +++ b/Test/Integration/NameController/Data/NamesStateAndSubmittedByTestData.cs @@ -1,18 +1,21 @@ using System.Collections; using System.Collections.Generic; -using Core.Entities; +using AutoFixture; using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; using Core.Enums; namespace Test.Integration.NameController.Data; public class NamesStateAndSubmittedByTestData : IEnumerable { + + private const string CreatedByAdeshina = "Adeshina"; + private const string CreatedByIsmaila = "Ismaila"; + public IEnumerator GetEnumerator() { - yield return new object[] {NameEntries(), State.NEW, "Adeshina"}; - yield return new object[] {NameEntries(), State.PUBLISHED, "Ismaila"}; + yield return new object[] {NameEntries(), State.NEW, CreatedByAdeshina}; + yield return new object[] {NameEntries(), State.PUBLISHED, CreatedByIsmaila}; } IEnumerator IEnumerable.GetEnumerator() @@ -20,52 +23,24 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } - private List NameEntries() + private static List NameEntries() { - return new List - { - new NameEntry - { - Name = "Ibironke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - CreatedBy = "Adeshina", - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - }, - new NameEntry - { - Name = "Aderonke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - CreatedBy = "Ismaila", - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - } - }; + var fixture = new Fixture(); + return + [ + fixture.Build() + .With(ne => ne.State, State.PUBLISHED) + .With(ne => ne.CreatedBy, CreatedByAdeshina) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + .Create(), + + fixture.Build() + .With(ne => ne.State, State.PUBLISHED) + .With(ne => ne.CreatedBy, CreatedByIsmaila) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + .Create() + ]; } } \ No newline at end of file diff --git a/Test/Integration/NameController/Data/NamesStateCountAndSubmittedByTestData.cs b/Test/Integration/NameController/Data/NamesStateCountAndSubmittedByTestData.cs index 5b14b84..39f769c 100644 --- a/Test/Integration/NameController/Data/NamesStateCountAndSubmittedByTestData.cs +++ b/Test/Integration/NameController/Data/NamesStateCountAndSubmittedByTestData.cs @@ -1,18 +1,20 @@ using System.Collections; using System.Collections.Generic; -using Core.Entities; +using AutoFixture; using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; using Core.Enums; namespace Test.Integration.NameController.Data; public class NamesStateCountAndSubmittedByTestData : IEnumerable { + private const string CreatedByAdeshina = "Adeshina"; + private const string CreatedByIsmaila = "Ismaila"; + public IEnumerator GetEnumerator() { - yield return new object[] {NameEntries(), State.NEW, 4, "Adeshina"}; - yield return new object[] {NameEntries(), State.PUBLISHED, 5, "Ismaila"}; + yield return new object[] { NameEntries(), State.NEW, 4, CreatedByAdeshina }; + yield return new object[] { NameEntries(), State.PUBLISHED, 5, CreatedByIsmaila }; } IEnumerator IEnumerable.GetEnumerator() @@ -20,52 +22,24 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } - private List NameEntries() + private static List NameEntries() { - return new List - { - new NameEntry - { - Name = "Ibironke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - CreatedBy = "Adeshina", - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - }, - new NameEntry - { - Name = "Aderonke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - CreatedBy = "Ismaila", - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - } - }; + var fixture = new Fixture(); + return + [ + fixture.Build() + .With(ne => ne.State, State.PUBLISHED) + .With(ne => ne.CreatedBy, CreatedByAdeshina) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + .Create(), + + fixture.Build() + .With(ne => ne.State, State.PUBLISHED) + .With(ne => ne.CreatedBy, CreatedByIsmaila) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + .Create() + ]; } } \ No newline at end of file diff --git a/Test/Integration/NameController/Data/NamesStateTestData.cs b/Test/Integration/NameController/Data/NamesStateTestData.cs index 6b0a96f..ce0c61b 100644 --- a/Test/Integration/NameController/Data/NamesStateTestData.cs +++ b/Test/Integration/NameController/Data/NamesStateTestData.cs @@ -1,8 +1,8 @@ using System.Collections; using System.Collections.Generic; -using Core.Entities; +using System.Linq; +using AutoFixture; using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; using Core.Enums; namespace Test.Integration.NameController.Data; @@ -11,10 +11,10 @@ public class NamesStateTestData : IEnumerable { public IEnumerator GetEnumerator() { - yield return new object[] {NameEntries(), State.NEW }; - yield return new object[] {NameEntries(), State.MODIFIED }; - yield return new object[] {NameEntries(), State.PUBLISHED }; - yield return new object[] {NameEntries(), State.UNPUBLISHED }; + yield return new object[] { NameEntries(), State.NEW }; + yield return new object[] { NameEntries(), State.MODIFIED }; + yield return new object[] { NameEntries(), State.PUBLISHED }; + yield return new object[] { NameEntries(), State.UNPUBLISHED }; } IEnumerator IEnumerable.GetEnumerator() @@ -22,50 +22,16 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } - private List NameEntries() + private static List NameEntries() { - return new List - { - new NameEntry - { - Name = "Ibironke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - }, - new NameEntry - { - Name = "Aderonke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - } - }; + var fixture = new Fixture(); + + fixture.Customize(c => c + .With(x => x.State, State.PUBLISHED) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + ); + + return fixture.CreateMany(2).ToList(); } } \ No newline at end of file diff --git a/Test/Integration/NameController/Data/NamesSubmittedByTestData.cs b/Test/Integration/NameController/Data/NamesSubmittedByTestData.cs index d511648..12b9acb 100644 --- a/Test/Integration/NameController/Data/NamesSubmittedByTestData.cs +++ b/Test/Integration/NameController/Data/NamesSubmittedByTestData.cs @@ -1,18 +1,20 @@ using System.Collections; using System.Collections.Generic; -using Core.Entities; +using AutoFixture; using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; using Core.Enums; namespace Test.Integration.NameController.Data; public class NamesSubmittedByTestData : IEnumerable { + private const string CreatedByAdeshina = "Adeshina"; + private const string CreatedByIsmaila = "Ismaila"; + public IEnumerator GetEnumerator() { - yield return new object[] {NameEntries(), "Adeshina"}; - yield return new object[] {NameEntries(), "Ismaila"}; + yield return new object[] {NameEntries(), CreatedByAdeshina}; + yield return new object[] {NameEntries(), CreatedByIsmaila}; } IEnumerator IEnumerable.GetEnumerator() @@ -20,52 +22,24 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } - private List NameEntries() + private static List NameEntries() { - return new List - { - new NameEntry - { - Name = "Ibironke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - CreatedBy = "Adeshina", - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - }, - new NameEntry - { - Name = "Aderonke", - Meaning = "The man of valor", - Morphology = new List { "He", "Ho" }, - Media = new List { "Me", "Dia" }, - State = State.PUBLISHED, - CreatedBy = "Ismaila", - Etymology = new List - { - new Etymology(part: "Part1", meaning: "Meaning 1") - }, - Videos = new List - { - new EmbeddedVideo(videoId: "Video ID 1", caption: "Caption 1") - }, - GeoLocation = new List - { - new GeoLocation(place: "Lagos", region: "South-West") - } - } - }; + var fixture = new Fixture(); + return + [ + fixture.Build() + .With(ne => ne.State, State.PUBLISHED) + .With(ne => ne.CreatedBy, CreatedByAdeshina) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + .Create(), + + fixture.Build() + .With(ne => ne.State, State.PUBLISHED) + .With(ne => ne.CreatedBy, CreatedByIsmaila) + .With(ne => ne.Modified, (NameEntry?)default) + .With(ne => ne.Duplicates, []) + .Create() + ]; } } \ No newline at end of file diff --git a/Test/Integration/NameController/NameControllerTest.cs b/Test/Integration/NameController/NameControllerTest.cs index 27721a4..7d511ac 100644 --- a/Test/Integration/NameController/NameControllerTest.cs +++ b/Test/Integration/NameController/NameControllerTest.cs @@ -1,227 +1,232 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using Core.Dto.Response; using Core.Entities.NameEntry; -using Core.Enums; using Core.Repositories; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; +using System.Collections.Generic; +using System.Net.Http; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; using Test.Integration.NameController.Data; using Xunit; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Core.Enums; +using FluentAssertions; -namespace Test.Integration.NameController; - -[Collection("Shared_Test_Collection")] -public class NameControllerTest : IAsyncLifetime +namespace Test.Integration.NameController { - private readonly HttpClient _client; - private readonly BootStrappedApiFactory _bootStrappedApiFactory; - - public NameControllerTest(BootStrappedApiFactory bootStrappedApiFactory) + [Collection("Shared_Test_Collection")] + public class NameControllerTest : IAsyncLifetime { - _bootStrappedApiFactory = bootStrappedApiFactory; - _client = _bootStrappedApiFactory.HttpClient; - } + private readonly HttpClient _client; + private readonly BootStrappedApiFactory _bootStrappedApiFactory; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly INameEntryRepository _nameEntryRepository; - [Theory] - [ClassData(typeof(NamesAllTestData))] - public async Task TestGetAllNamesEndpointProvidingOnlyAllParameter(List seed) - { - using var scope = _bootStrappedApiFactory.Server.Services.CreateScope(); - // Arrange - var nameEntryRepository = scope.ServiceProvider.GetRequiredService(); - await nameEntryRepository.Create(seed); - var url = "/api/v1/Names?all=true"; - // Act - var result = await _client.GetAsync(url); - var responseContent = await result.Content.ReadAsStringAsync(); - var nameEntryDtos = JsonConvert.DeserializeObject(responseContent); - // Assert - Assert.Equal(HttpStatusCode.OK, result.StatusCode); - Assert.Equal(seed.Count(), nameEntryDtos!.Length); - for (int i = 0; i < seed.Count; i++) + public NameControllerTest(BootStrappedApiFactory bootStrappedApiFactory) { - Assert.Equal(seed[i].Name, nameEntryDtos[i].Name); + _bootStrappedApiFactory = bootStrappedApiFactory; + _client = _bootStrappedApiFactory.HttpClient; + _jsonSerializerOptions = _bootStrappedApiFactory.JsonSerializerOptions; + + using var scope = _bootStrappedApiFactory.Server.Services.CreateScope(); + _nameEntryRepository = scope.ServiceProvider.GetRequiredService(); } - } - [Theory] - [ClassData(typeof(NamesStateTestData))] - public async Task TestGetAllNamesEndpointProvidingOnlyState(List seed, State state) - { - using var scope = _bootStrappedApiFactory.Server.Services.CreateScope(); - // Arrange - var nameEntryRepository = scope.ServiceProvider.GetRequiredService(); - var filteredSeed = seed.Where(x => x.State == state).ToList(); - await nameEntryRepository.Create(seed); - var url = $"/api/v1/Names?state={state}"; - // Act - var result = await _client.GetAsync(url); - var responseContent = await result.Content.ReadAsStringAsync(); - var nameEntryDtos = JsonConvert.DeserializeObject(responseContent); - // Assert - Assert.Equal(HttpStatusCode.OK, result.StatusCode); - Assert.Equal(filteredSeed.Count, nameEntryDtos!.Length); - for (int i = 0; i < filteredSeed.Count; i++) + [Theory] + [ClassData(typeof(NamesAllTestData))] + public async Task TestGetAllNamesEndpointProvidingOnlyAllParameter(List seed) { - Assert.Equal(filteredSeed[i].Name, nameEntryDtos[i].Name); + // Arrange + await _nameEntryRepository.Create(seed); + + var url = "/api/v1/Names?all=true"; + + // Act + var result = await _client.GetAsync(url); + var responseContent = await result.Content.ReadAsStringAsync(); + var nameEntryDtos = JsonSerializer.Deserialize(responseContent, _jsonSerializerOptions); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(seed.Count, nameEntryDtos!.Length); + nameEntryDtos + .Select(dto => dto.Name) + .Should() + .BeEquivalentTo(seed.Select(s => s.Name)); } - } - - [Theory] - [ClassData(typeof(NamesCountTestData))] - public async Task TestGetAllNamesEndpointProvidingOnlyCount(List seed, int count) - { - using var scope = _bootStrappedApiFactory.Server.Services.CreateScope(); - // Arrange - var nameEntryRepository = scope.ServiceProvider.GetRequiredService(); - await nameEntryRepository.Create(seed); - var expectedCount = seed.Count > count ? count : seed.Count; - var url = $"/api/v1/Names?count={count}"; - // Act - var result = await _client.GetAsync(url); - var responseContent = await result.Content.ReadAsStringAsync(); - var nameEntryDtos = JsonConvert.DeserializeObject(responseContent); - // Assert - Assert.Equal(HttpStatusCode.OK, result.StatusCode); - Assert.Equal(expectedCount, nameEntryDtos!.Length); - for (int i = 0; i < expectedCount; i++) + + [Theory] + [ClassData(typeof(NamesStateTestData))] + public async Task TestGetAllNamesEndpointProvidingOnlyState(List seed, State state) { - Assert.Equal(seed[i].Name, nameEntryDtos[i].Name); + // Arrange + var filteredSeed = seed.Where(x => x.State == state).ToList(); + await _nameEntryRepository.Create(seed.ToList()); + var url = $"/api/v1/Names?state={state}"; + + // Act + var result = await _client.GetAsync(url); + var responseContent = await result.Content.ReadAsStringAsync(); + var nameEntryDtos = JsonSerializer.Deserialize(responseContent, _jsonSerializerOptions); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(filteredSeed.Count, nameEntryDtos!.Length); + nameEntryDtos + .Select(dto => dto.Name) + .Should() + .BeEquivalentTo(filteredSeed.Select(s => s.Name)); } - } - [Theory] - [ClassData(typeof(NamesSubmittedByTestData))] - public async Task TestGetAllNamesEndpointProvidingOnlySubmittedBy(List seed, string creator) - { - using var scope = _bootStrappedApiFactory.Server.Services.CreateScope(); - // Arrange - var nameEntryRepository = scope.ServiceProvider.GetRequiredService(); - await nameEntryRepository.Create(seed); - var expectedData = seed.Where(x => x.CreatedBy == creator).ToList(); - var url = $"/api/v1/Names?submittedBy={creator}"; - // Act - var result = await _client.GetAsync(url); - var responseContent = await result.Content.ReadAsStringAsync(); - var nameEntryDtos = JsonConvert.DeserializeObject(responseContent); - // Assert - Assert.Equal(HttpStatusCode.OK, result.StatusCode); - Assert.Equal(expectedData.Count, nameEntryDtos!.Length); - for (int i = 0; i < expectedData.Count(); i++) + [Theory] + [ClassData(typeof(NamesCountTestData))] + public async Task TestGetAllNamesEndpointProvidingOnlyCount(List seed, int count) { - Assert.Equal(expectedData[i].Name, nameEntryDtos[i].Name); + // Arrange + await _nameEntryRepository.Create(seed); + var url = $"/api/v1/Names?count={count}"; + + // Act + var result = await _client.GetAsync(url); + var responseContent = await result.Content.ReadAsStringAsync(); + var namesResponse = JsonSerializer.Deserialize(responseContent, _jsonSerializerOptions); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(count, namesResponse!.Length); + namesResponse + .Select(dto => dto.Name) + .Should() + .BeSubsetOf(seed.Select(s => s.Name)); } - } - - [Theory] - [ClassData(typeof(NamesStateAndCountTestData))] - public async Task TestGetAllNamesEndpointProvidingOnlyStateAndCount(List seed, State state, int count) - { - using var scope = _bootStrappedApiFactory.Server.Services.CreateScope(); - // Arrange - var nameEntryRepository = scope.ServiceProvider.GetRequiredService(); - var filteredSeed = seed.Where(x => x.State == state).ToList(); - var expectedCount = filteredSeed.Count > count ? count : filteredSeed.Count; - await nameEntryRepository.Create(seed); - var url = $"/api/v1/Names?state={state}&count={count}"; - // Act - var result = await _client.GetAsync(url); - var responseContent = await result.Content.ReadAsStringAsync(); - var nameEntryDtos = JsonConvert.DeserializeObject(responseContent); - // Assert - Assert.Equal(HttpStatusCode.OK, result.StatusCode); - Assert.Equal(expectedCount, nameEntryDtos!.Length); - for (int i = 0; i < expectedCount; i++) + + [Theory] + [ClassData(typeof(NamesSubmittedByTestData))] + public async Task TestGetAllNamesEndpointProvidingOnlySubmittedBy(List seed, string creator) { - Assert.Equal(filteredSeed[i].Name, nameEntryDtos[i].Name); + // Arrange + await _nameEntryRepository.Create(seed); + var expectedData = seed.Where(x => x.CreatedBy == creator).ToList(); + var url = $"/api/v1/Names?submittedBy={creator}"; + + // Act + var result = await _client.GetAsync(url); + var responseContent = await result.Content.ReadAsStringAsync(); + var nameEntryDtos = JsonSerializer.Deserialize(responseContent, _jsonSerializerOptions); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(expectedData.Count, nameEntryDtos!.Length); + nameEntryDtos + .Select(dto => dto.Name) + .Should() + .Equal(expectedData.Select(ed => ed.Name)); } - } - - [Theory] - [ClassData(typeof(NamesStateAndSubmittedByTestData))] - public async Task TestGetAllNamesEndpointProvidingOnlyStateAndSubmittedBy(List seed, State state, string submittedBy) - { - using var scope = _bootStrappedApiFactory.Server.Services.CreateScope(); - // Arrange - var nameEntryRepository = scope.ServiceProvider.GetRequiredService(); - var filteredSeed = seed.Where(x => x.State == state && x.CreatedBy == submittedBy).ToList(); - await nameEntryRepository.Create(seed); - var url = $"/api/v1/Names?state={state}&submittedBy={submittedBy}"; - // Act - var result = await _client.GetAsync(url); - var responseContent = await result.Content.ReadAsStringAsync(); - var nameEntryDtos = JsonConvert.DeserializeObject(responseContent); - // Assert - Assert.Equal(HttpStatusCode.OK, result.StatusCode); - Assert.Equal(filteredSeed.Count, nameEntryDtos!.Length); - for (int i = 0; i < filteredSeed.Count; i++) + + [Theory] + [ClassData(typeof(NamesStateAndCountTestData))] + public async Task TestGetAllNamesEndpointProvidingOnlyStateAndCount(List seed, State state, int count) { - Assert.Equal(filteredSeed[i].Name, nameEntryDtos[i].Name); + // Arrange + var filteredSeed = seed.Where(x => x.State == state).ToList(); + var expectedCount = filteredSeed.Count > count ? count : filteredSeed.Count; + await _nameEntryRepository.Create(seed); + var url = $"/api/v1/Names?state={state}&count={count}"; + + // Act + var result = await _client.GetAsync(url); + var responseContent = await result.Content.ReadAsStringAsync(); + var namesResponse = JsonSerializer.Deserialize(responseContent, _jsonSerializerOptions); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(expectedCount, namesResponse!.Length); + namesResponse + .Select(dto => dto.Name) + .Should() + .BeEquivalentTo(filteredSeed.Select(s => s.Name)); } - } - - [Theory] - [ClassData(typeof(NamesCountAndSubmittedByTestData))] - public async Task TestGetAllNamesEndpointProvidingOnlyCountAndSubmittedBy(List seed, int count, string submittedBy) - { - using var scope = _bootStrappedApiFactory.Server.Services.CreateScope(); - // Arrange - var nameEntryRepository = scope.ServiceProvider.GetRequiredService(); - var filteredSeed = seed.Where(x => x.CreatedBy == submittedBy).Take(count).ToList(); - var expectedCount = filteredSeed.Count > count ? count : filteredSeed.Count; - await nameEntryRepository.Create(seed); - var url = $"/api/v1/Names?count={count}&submittedBy={submittedBy}"; - // Act - var result = await _client.GetAsync(url); - var responseContent = await result.Content.ReadAsStringAsync(); - var nameEntryDtos = JsonConvert.DeserializeObject(responseContent); - // Assert - Assert.Equal(HttpStatusCode.OK, result.StatusCode); - Assert.Equal(expectedCount, nameEntryDtos!.Length); - for (int i = 0; i < expectedCount; i++) + + [Theory] + [ClassData(typeof(NamesStateAndSubmittedByTestData))] + public async Task TestGetAllNamesEndpointProvidingOnlyStateAndSubmittedBy(List seed, State state, string submittedBy) { - Assert.Equal(filteredSeed[i].Name, nameEntryDtos[i].Name); + // Arrange + var filteredSeed = seed.Where(x => x.State == state && x.CreatedBy == submittedBy).ToList(); + await _nameEntryRepository.Create(seed); + var url = $"/api/v1/Names?state={state}&submittedBy={submittedBy}"; + + // Act + var result = await _client.GetAsync(url); + var responseContent = await result.Content.ReadAsStringAsync(); + var namesResponse = JsonSerializer.Deserialize(responseContent, _jsonSerializerOptions); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(filteredSeed.Count, namesResponse!.Length); + namesResponse + .Select(dto => dto.Name) + .Should() + .BeEquivalentTo(filteredSeed.Select(s => s.Name)); } - } - - [Theory] - [ClassData(typeof(NamesStateCountAndSubmittedByTestData))] - public async Task TestGetAllNamesEndpointProvidingOnlyState_CountAndSubmittedBy(List seed, State state, int count, string submittedBy) - { - using var scope = _bootStrappedApiFactory.Server.Services.CreateScope(); - // Arrange - var nameEntryRepository = scope.ServiceProvider.GetRequiredService(); - var filteredSeed = seed.Where(x => x.State == state && x.CreatedBy == submittedBy).Take(count).ToList(); - var expectedCount = filteredSeed.Count > count ? count : filteredSeed.Count; - await nameEntryRepository.Create(seed); - var url = $"/api/v1/Names?state={state}&count={count}&submittedBy={submittedBy}"; - // Act - var result = await _client.GetAsync(url); - var responseContent = await result.Content.ReadAsStringAsync(); - var nameEntryDtos = JsonConvert.DeserializeObject(responseContent); - // Assert - Assert.Equal(HttpStatusCode.OK, result.StatusCode); - Assert.Equal(expectedCount, nameEntryDtos!.Length); - for (int i = 0; i < expectedCount; i++) + + [Theory] + [ClassData(typeof(NamesCountAndSubmittedByTestData))] + public async Task TestGetAllNamesEndpointProvidingOnlyCountAndSubmittedBy(List seed, int count, string submittedBy) { - Assert.Equal(filteredSeed[i].Name, nameEntryDtos[i].Name); + // Arrange + var filteredSeed = seed.Where(x => x.CreatedBy == submittedBy).Take(count).ToList(); + var expectedCount = filteredSeed.Count > count ? count : filteredSeed.Count; + await _nameEntryRepository.Create(seed); + var url = $"/api/v1/Names?count={count}&submittedBy={submittedBy}"; + + // Act + var result = await _client.GetAsync(url); + var responseContent = await result.Content.ReadAsStringAsync(); + var namesResponse = JsonSerializer.Deserialize(responseContent, _jsonSerializerOptions); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(expectedCount, namesResponse!.Length); + namesResponse + .Select(dto => dto.Name) + .Should() + .BeEquivalentTo(filteredSeed.Select(s => s.Name)); } - } - public Task InitializeAsync() - { - return Task.CompletedTask; - } + [Theory] + [ClassData(typeof(NamesStateCountAndSubmittedByTestData))] + public async Task TestGetAllNamesEndpointProvidingOnlyState_CountAndSubmittedBy(List seed, State state, int count, string submittedBy) + { + // Arrange + var filteredSeed = seed.Where(x => x.State == state && x.CreatedBy == submittedBy).Take(count).ToList(); + var expectedCount = filteredSeed.Count > count ? count : filteredSeed.Count; + await _nameEntryRepository.Create(seed); + var url = $"/api/v1/Names?state={state}&count={count}&submittedBy={submittedBy}"; - public async Task DisposeAsync() - { - using var scope = _bootStrappedApiFactory.Server.Services.CreateScope(); - var nameEntryRepository = scope.ServiceProvider.GetRequiredService(); - await nameEntryRepository.DeleteAll(); - } -} + // Act + var result = await _client.GetAsync(url); + var responseContent = await result.Content.ReadAsStringAsync(); + var namesResponse = JsonSerializer.Deserialize(responseContent, _jsonSerializerOptions); + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(expectedCount, namesResponse!.Length); + namesResponse + .Select(dto => dto.Name) + .Should() + .BeEquivalentTo(filteredSeed.Select(s => s.Name)); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await _nameEntryRepository.DeleteAll(); + } + } +} \ No newline at end of file diff --git a/Test/Test.csproj b/Test/Test.csproj index 711091c..563dbd2 100644 --- a/Test/Test.csproj +++ b/Test/Test.csproj @@ -5,6 +5,8 @@ false + + diff --git a/Website/Program.cs b/Website/Program.cs index 70e3c6c..b9ea9e6 100644 --- a/Website/Program.cs +++ b/Website/Program.cs @@ -1,10 +1,10 @@ +using Core.StringObjectConverters; using Microsoft.AspNetCore.Localization; using ProxyKit; using System.Globalization; using Website.Config; using Website.Middleware; using Website.Services; -using Website.Utilities; namespace Website { From 6ffa9a9b301684619a28e5cf3f6b478bed4ce35d Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Fri, 30 Aug 2024 17:15:28 +0300 Subject: [PATCH 09/20] Prevent name posting service from crashing. (#99) Composing the tweet has a big potential to fail since it involves a call to the database. --- Infrastructure/Services/NamePostingService.cs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Infrastructure/Services/NamePostingService.cs b/Infrastructure/Services/NamePostingService.cs index 54274d0..37d785c 100644 --- a/Infrastructure/Services/NamePostingService.cs +++ b/Infrastructure/Services/NamePostingService.cs @@ -34,16 +34,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) continue; } - string? tweetText = await BuildTweet(indexedName.Name); - - if (string.IsNullOrWhiteSpace(tweetText)) - { - _logger.LogWarning(TweetComposeFailure, indexedName.Name); - continue; - } - try { + string? tweetText = await BuildTweet(indexedName.Name); + + if (string.IsNullOrWhiteSpace(tweetText)) + { + _logger.LogWarning(TweetComposeFailure, indexedName.Name); + continue; + } + var tweet = await _twitterApiClient.PostTweet(tweetText); if (tweet != null) { @@ -53,7 +53,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) catch (Exception ex) { _logger.LogError(ex, "Failed to tweet name: {name} to Twitter.", indexedName.Name); - _nameQueue.Enqueue(indexedName); } await Task.Delay(tweetIntervalMs, stoppingToken); From 4c758d4d75f80fc66650ac325a439a01192d93e5 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Mon, 2 Sep 2024 15:43:31 +0300 Subject: [PATCH 10/20] Fix social media sharing tags. (#102) --- Website/Pages/Shared/BasePageModel.cshtml.cs | 6 +++++- Website/Pages/Shared/_Layout.cshtml | 14 +++++++------- Website/Pages/SingleEntry.cshtml | 4 ++-- Website/Pages/SingleEntry.cshtml.cs | 6 ++++-- Website/wwwroot/img/yn-logo-drum-only.png | Bin 0 -> 37210 bytes 5 files changed, 18 insertions(+), 12 deletions(-) create mode 100644 Website/wwwroot/img/yn-logo-drum-only.png diff --git a/Website/Pages/Shared/BasePageModel.cshtml.cs b/Website/Pages/Shared/BasePageModel.cshtml.cs index c4c0cd7..c159d55 100644 --- a/Website/Pages/Shared/BasePageModel.cshtml.cs +++ b/Website/Pages/Shared/BasePageModel.cshtml.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Localization; +using System.Web; using Website.Resources; namespace Website.Pages.Shared @@ -17,9 +18,12 @@ public override void OnPageHandlerExecuting(PageHandlerExecutingContext context) { base.OnPageHandlerExecuting(context); + var host = HttpUtility.UrlEncode($"{Request.Scheme}://{Request.Host}"); + // Some of the strings below should be internationalized. ViewData["Description"] = "YorubaNames"; - ViewData["SocialURL"] = "http://www.yorubaname.com"; + ViewData["BaseURL"] = host; + ViewData["SocialPath"] = string.Empty; // HomePage path ViewData["SocialTitle"] = "YorubaNames"; ViewData["SocialDescription"] = "Over 10,000 Yoruba names and still growing..."; } diff --git a/Website/Pages/Shared/_Layout.cshtml b/Website/Pages/Shared/_Layout.cshtml index 02de21b..02d6472 100644 --- a/Website/Pages/Shared/_Layout.cshtml +++ b/Website/Pages/Shared/_Layout.cshtml @@ -11,15 +11,15 @@ - + - - - - - - + + + + + + diff --git a/Website/Pages/SingleEntry.cshtml b/Website/Pages/SingleEntry.cshtml index b04ab38..9addde1 100644 --- a/Website/Pages/SingleEntry.cshtml +++ b/Website/Pages/SingleEntry.cshtml @@ -31,8 +31,8 @@ @Localizer["improve-entry"] @Localizer["share"] - - + +

diff --git a/Website/Pages/SingleEntry.cshtml.cs b/Website/Pages/SingleEntry.cshtml.cs index f8bbc2e..3933046 100644 --- a/Website/Pages/SingleEntry.cshtml.cs +++ b/Website/Pages/SingleEntry.cshtml.cs @@ -17,7 +17,6 @@ public class SingleEntryModel( public NameEntryDto Name { get; set; } = new NameEntryDto(); public List Letters { get; private set; } = []; - public string Host { get; set; } = string.Empty; public string[] MostPopularNames { get; set; } = []; @@ -31,9 +30,12 @@ public async Task OnGet(string nameEntry) return Redirect($"/entries?q={HttpUtility.UrlEncode(nameEntry)}"); } + ViewData["SocialTitle"] = name.Name; + ViewData["SocialPath"] = $"/entries/{nameEntry}"; + ViewData["SocialDescription"] = name.Meaning; + Name = name; Letters = YorubaAlphabetService.YorubaAlphabet; - Host = HttpUtility.UrlEncode($"{Request.Scheme}://{Request.Host}"); var searchActivity = await _apiService.GetRecentStats(); MostPopularNames = searchActivity.MostPopular; diff --git a/Website/wwwroot/img/yn-logo-drum-only.png b/Website/wwwroot/img/yn-logo-drum-only.png new file mode 100644 index 0000000000000000000000000000000000000000..4fd1ec29a46a70546b910e08dff7b8eb0fc1bcfe GIT binary patch literal 37210 zcmd3rWm_Cgvw#;{9D=(O++Blv@Zjzai$ib=4vV|H6C^-zcXxMpcX;!BKR@7n=;`UJ zyGHJ*nyRh}S5lBdM!-h^0077`(&8%r`nrGZ01o)?=$Nz_{;z{@R*@0~R8J5d{~JJC zh{%fo0Ka1q-;7}Xjp0GkTFw9fO80*aqB+jy8~^}c%7}~n@X$Yhht0HIRwIt6Nl@LN zYH^rgcczwOpOFp{2aBUaQq+2Wm;`nI5?Wd!J+sJ$LNR2S@SWsBnS6*fgeHe4gO`z} zke^`Z9_+|!t)LH0=zU$N?l3kDloU;GiMC!ZE2}Pdepo)tbnrRV;U`L8>Z9WN9WHNc zjFIvG;S~4#iq9vzUF_$}D{*nu2UC#??68#@mDc9_@w~RE)k$&PTH@>(UG>Us8D;yY z{N}@C&gQ$1aZSCC1mJnt;Pv+;m{PFe^;*vAR7H04X29X|aPq=8d+F5l=HfNSD#7$# z$L;e?W!0N%mR<4Vxx!ZDe&+i&lkxrYJ{+~}GQ(X+{>-(u z2F}WHR+D}d&&T?4H>JC?1%i&Gu>h5hvBWvFjUUTpEcKL4&f zH^cL1^N#3qWc9xVT^&2P&&BQ6tG*Z9GYs$X)RtDVX_cj)c8|M#`V_AbW_uYAPq4mc zS!^;S8}CnF>oxP9Lp%0!9L{_*mr|BHjPd?SAODl4P)YvQ_K%6Vb>jQux)AjP{*s0O z#b!f^&{e@>38hf`zd2*t>GlNsv&DgqoquBMpH-hb%gnAN;fXV!OYhFc2Id(6&qc~` zHvi{L%CCdGMyu~Hie~ZOn}n`yE?-~PV=-RYR-lJ!Sb(?xI5O7^)it2@bZ!2(NF`Zb zn?)Zg1Muv}{%^MP-ePfHJWFP_;l9M&LMz?(M8of`^*_#|rU|B(w@d#Bk;G=DlK8zC zGv1Rt{qZw8_f1_&>86T$^E?Z1cs)eal4QQ{eIu9W%6aN~^)-L&?dw3|GTPIyGCVNo zUiBKx>EOw5(erz@?HQ_>=^EP#E4NFs?4kSTKmJqyp7$$9c2kP-19EfeYrs_t$LL7g z9iUJeVC9IM^Ld={u{P=B?SA`z8Q%l-a^d{WsHIjit@vRgy~f>S`-GI+rqNpoC6WAK z4ow{8@xdm>E94nH@eqK(0Ls_#wRT_&DwVvAzytLv!o5lPn$A4UAihT$xkm}*s)=>o#P83v{$EdNUpbYL#Jn-Mk}N~@XB`H9IZ5EvE-t2_pzr0Lszdk_R=Se zwyhes^sAs-`8Gn@qkY&cBL4ZrR!5_lAO$&*y#s)w4C{Se;@2jNI)RM-a0MCC(K<>f zDP73zSH~0*rZP&S$qaBe758~Pj?O=QV>@eoJv>~Z=Tffi_Q*(736Ovh8pRw`VUUrb zlsWmsK#((y^qLT1pLzaBkP%~j^ebDDvSMuz1<9%UC`ap+9d(a`XdF#ztd9T5(w5Pn z{JWy!?DF8Z?3}cUb`e>=*Bw)klFE6Lg$PGRaXckIFWb#maQ<*5+*MTTEkIc~T)BVr z>!62XciR&a%;QK~7Qz~2pB0_3An<`6Dc9`F?PHEfD@p(48Z1i55MY7T8{-1G54ANS zs1q`koiHKx>ss)mlpgMuV|=03*|61~GJ1$sWR?^fT zYY#>}DVGYjEeDH4cp5kL2|ya%x!N9#02S*QAAmrLU>I%JHhGv%g8`Ib{eT)+h0xq_ zI7UT@VC(MNsS#T7sJOla(YO(x(RX)u&0V#Cce)A;r}BKA)9Ok$ZVzny)@(NIt{o>+Ra?`4Qr^2Y1NByvhKUNiL;$B}N^ z+`~%JilN8(y0Y^=0oUaF$Kv7vhjpVjox-MEeUqTHdG#MeL7*a%ZA?HHjmZ##8x|Gm zfI|0X#CaO0D%y^FQ$P0Gqr8})+vkfe#%iQkl;7uCl|F>$TT7(XhFBXS_sg04Qz%~; z`V+GB0b7dzu#pG#n&f2oY&;%Xs82uRuZ~7(;~V8(OIBapf{wuRR)Tr^hr|9`kMr}J zZe$Hv#FFYm%i=Y5BaYC^Ev#J3qv8*Q4Y4v?t@=S3`mxr+G6RFsOyA%pqph9j%e=og zGtLgUE)#EH$&AZmMxh$&c!m1VP2q_%k zEA^B%1un)hFwCM`UXuLe%0xvta!qT+Ru>I&8*eAU`+HU&cdLu4jSt-|{i`35daIA| zOSt^oxZH>>X6FZZn{{pv1)m>GVs@W(_Dj@A6jIOL*?YF)PR|ojPDRS8w4u%|z5HY3 zDf%AX9j^ENpB$P#7X)YJG6e_7d>>oAKXjW@(EA}S$cQ+i_c$>9%@%gNhD6` zuQzdNApbPW`T<%q-r(*dd{QU1sC(nwI*Bg5W}hB29<;#u!VuO=^i4!i(XoU3;X;GI z_@V{mZW(m&>c0FN#VZ0DiKvwnnWN)Qd?*OU1Vd=A65>6u)}iOy0P9O1eF# zLVtHPtjq57Let`6i{sc^3H$17uia3IjZ1mdD|PQJIT_Qaf45(}%iYCLdz zb9XaQW+K@rY~(R1a~o)s9SGF7Di$WYjpXGYaA@9M3caTIvsy)v5qJ*M)t7>ldM&5E zH`!4e=iw;JnT**6-y36w0EIX>WZ#h+qKwNgG16s1zSbWL=JY0=%VG{JW&T`U&!!)6 zHCmIUwVte^9lO$^)PEw;*M--Gii?VgWiF#Di;xK2e6lYm=9RYg5^rxzxt~5s@VkAX z_Pur=e#Cix;sM(H$tRRtcbM&&F5umG#E3qLuGg`5JHV5&dkEdA3cb}3 zblz=@zkr^#%RE0)oU(2m7z?4CB#E?r?_@?Q$v1u1q=`r)r2{4++1^qL>EFk3K6dX9 zh4k*+yz5*e2?q;vqcdy)eclmWs^r>ahDeyTtU=oG5SDF}3u9}>-Iw0Z*XZFG79VfM zwJcwhu$$n*KG1ww{e#)tZu(!mkOn3L!yj8@Y6DH(A(*=Qy7vBVZ>Cl&14(WVB0IM? z)%*$X`wfwJtLxi z#=Mya8PCm=Y&6$5`EENH!G0?p_1cISNb*`gTYl{9e4-b+ZvT{ZaM0y)tNx7sxloxr z7mPGaAvGyI>5VXkUKGL`50$f1i258_i$Geq_9x=$^yly|$`(~sMD|3nuru4rnQnd@ zTIW^{?RJ37mq>zvrYr}J&t4oz(Aq-kWS$DFEtr{6-cB^ zPDq5;@q^5Gevv6)6aZWdcQRfLjo2I<42yGp$@S)&ACt@5;st-y=bk*wYCZe!N<#~R z&L^%BP~h(?Jbu(5eYJnKL_~7fzYhl*%;!n_j3MLIq#o2escDxsy7fe;|At)cpnUko zS-$4Qy}VEB)LNCW6w+?F2<0H?weJ7M|M?`;On-q{u1{-Agl!f{umLd|7th# zBNFJ7Q!mju@*lwTWH{1v`Y^Q3i-7Ukb-t`of(>6l_ojskB=JGKTwOB2Nd1=0nW& z*ZykxU9^tU5?dG(X>=b7m)uj}3LsmCW)tT{`pIPRh})VP zrwQDC!~7chaNQPDP& zUjXJ$RnItS&h!cz9Dmi>M+PF&50TkwdoQ+@x((VTLJQhnI&%x2CTe&rR+-hbcnrcQ zqi{~X>)Ba63#oDVSyy9%sX{Z|0ENW&J{iEGWpkIKTpuDp+hN9scP|MCbI}J4@Jk{O z{#|^}^dNcri(be|_@^-+V5RBJMPnt-TgO?;##|x0Y44#x;uw#1+mJAZ7%)X*(MM@`jo4E(7v9lo8P`K&ImUFjs zzIRDBo7rZ4v72?Y>X>(L*N-{o+W_AiYCjdi7xnsUSHI{ek+;o`&(lvUraNC3114%I^U$yrH< zLVHJiY5Tm6ChgG`9J(#Sz4yp03Ze(o6dYbh9GnIpNW=Cyq6Q=66*Ro{kw3mU5%4w! z6&_ZUmFt+C?3*J1Lkwkscu+EEj0i}$-n1KE&0DW=o+5OJQYEwi0O2(@(k|YOUm?(+ zbb+}`C}vP$+>hIgxM62sAU&j~Ol#>cQz)S@yLE)s9S4yCBWLL>&bzOUtvoo9J{Yw^ zd-7-Gft(t&a8ijCMTpBbN)n@65(!J)fcO!(yh@mp{3UMLgilTQ{%HHM#a!ZOoTn$~r+ zqF<0-yu=HLIa4OcuCt(8CsaC{6n@17Giy-SG0M&pp5!W*soAK3$6G(d#PNLvnkx)`D9j!!~^_qU{S#4Jaf%XY37%H3DP^pJd3r{ zg$+$&@6}Q>YedTdtrv-zPDGu~&V(F)BESyVaOg12X7Jrr7+s8^USuTTlYogQj0Rz# z`NWSTdYI7eCI%}?{{)eQV8MJaY*`Vehu9l(E|83{Rst#FE&n15JC+dBQwiiO*Z#abq`+z4mLn6 zm5Ng9gpBvcHiw`%1L{nr=*G*}bpE$3#mUXv!w-lY;92rHuIIS_+wEn?yHme(j6#xa zdUm-ox+jM$(Mjf8y5GI)=54`R+b!>(p9k+#zxO}c+}~_E9Ud!$rkMmD96rp>P(REe z4bz%wf0ZRz&t21z-9Zlhf=^7tgrr1}FwBN%$x@@_J2kA~`l=%sajmmQQTQS_GJi|48o0b(3aKSFA`?vrL8p+{4ZrD@!?aF-*@7WTdDJ8Uiep zaRGnKSwjmLQiJ@yOC^9CzN?gJDL@M|ph}l4OO*s=TiSHbjt3|%N3u#*sFOsvk5@@n z-p>$i8p(htJZ>Zu^pbJ6#^+E8RvurnD=*H^5j95 zL(fG?%g#U8{?2xT7)hSI!t-qcz;GclEIcZv9s=;$6Z>OC&@a5F#Kv>3HdKWyO#|Rn zhW8|-HAgm#tqaI_VfMM{UC46RICA%UM?Vq1OMZQrJ+68G{kU4R@_Jj}bem8^uImtX zr8D^-#W8oEUBUW|{lntSFqBK7;K)eB>{dqth%(q_Z7c0+@)sWb&(W?UeTRI z36T+V+XplA)kQ=~% z5v0~G667L{$*!??z-T);^NX3e$sSkhES4suXNp&plr=i8wm(mfPcu5C=9=(-teoB=Nlk%r_I~` zLlq4I;J_G|8Q}%!6lIhy5t%G0ZIa;w8+0D(kaRx%Hh3=IEkV2!^0mSXQ#-1N(Z1Vi zdhdFT@wpBq zyg#0;u9F!@* zED8i$tN;t)C?%{+LXaf|y`gnxPEu$e2tl-9^e^LZ@mE@gxb9?HLh0S&1{djeDov;6 zAn^(*-%E7mS_S)8w)`H|y+&ryiX5`VNUJ@mJ=m#dP`a;sIIw^SfsDi$hmY?lZ$iUr z>0c~=frszLtm1x1?z*B;>ywfmjcm=`bTo2L;POy}c=vnr2{X&>>gjeMqporG*WwAv8^!JfH>2{|?PfubS@AKL0CuxD*)D#;{hNEIif9 z1h5=*hl`?guyQ-+U;qVXirZ}Y-a7$T%`JUyx=l1VSwX7Y4V5B_K z@G}lAOXiESvc)S_eg1-sl9<}OlP5Y|VkSDTXnEEF%1~yW6m&^AM|*lwRzJB=wSq7b zXFu-eT@2?HeNMUpMHW+5?zVzLOSyC;Z*cp5n`XBT4>Z-aQwe7aJ}*=RFh>UWNNLVi zA~dAQ_xC5zw`O=R)J>?+;zQSP<$=4=)zA9r;qNTUj4>uP6x<+R3_2gkhxJ3XPh**Y z{M<2^BS{_?TgT24loIT(4%8)lLACZu%Iw3z7i(Guc~$dfE80CaM1ajT$Syr;R6Kv= zVOLl~?u9W!MHwit*jqb{Id+xZ*M>q#r6vmBnhOwXxID@TF=ZpQ($qoKa8t@Yc&OG_ zxbQv7BGEUHIn%ZGm<-r;=J2~y5Fdp=R@&@W;Z&Ug?W&vaWIUt;o|!*#KJP-fL`?`s zF;OI^289W11`Zl)me=>{fo`6ydZiz~?FK(H$_yjLQF`Tql2Nx%jvjh}$A*50iyiZB zR{LiOs9;*bIc=?2)lQos!&M~VIm!elw}6Oywj=~>`Ni@d6N{vNH((54BbV4;$7^5} zvq9ilSG0jaiOu1}R7gtpfcs+Esx6eT{K~-N2ThTiyKuC6>>nL5Z5{o*{fZQ;=NV7! zmY+!r4!VWW1QwRg`)2-`rHj#}0n$^J4Q2D^v(Yxp)ANTylJtk!d6AO_g^`CER%ZSE zTz1-eTKPqC+($c+ILZfHdR`A^)=b_ZC)jEeaYrKEKo_GD}Wlg%so_^$M$y zWB-CAF$VE%T}%B7(Ha!^ez*z{<`VPJZc_8d5<&?xVl&n`@y(xLQF=if7l%lP-m<}& z9VJfk2iwL2>6w)owF198_NO8t51XN*3^U-6Sh^=@ds&rL1{PiG;8SUz?cGHUnqWqF zj@V?Sl#1)LF1{rH_Xo61SM|<>J@x2Ck*LB7Rt?@W`b4!d(vmGMr|vvf6O_fVWf09) zt+8l{5W7!!Wxt5S->zi3pyW`kv$Wekp)MQ}%GCVJ=XVr#Sz{}VEnFJZj!Bt)Q!0fb z+D`s+8$A%NOFp{W1-;8irG*Pw*z`vlzjTsycBTOCBxEyI_>Ef(f?J&JXknmQ$#EI2 zil+?1g}wqN+m8eW|A{F8+pxcwWuiN11dN|fkxdHwjT(sXBrINneF`0C5#S{jLP>N#K5qfHz(EqH+<-MQD6tT%m z>`Oh869Hu?2zSRcrg{&ed7#Rnh72r6 za{jjr7yiJN8?472*LDW_d(CS;S8UN$*S0lO!Fn;NHg|I;Yt*r$9=nc%!?||Hmx^_{ zuN^JRD-*f9G=I~=Lu7h7%#yqPfSk!Q309Jp7qDCd6Ti<7(jG%~rAHTz0*^2OdQ?y< z;&@B}Yd`bTqP*@nK8T{Kp}Od=7=p%)?L(L05W+Gg ze*!-f5M?cAnhL@dBge3{&H#Wy9;YpDdpP;F0Ac_^5-E1%I_Dt>O>$w$)LebFKz0u> z)nt`$9mG&+O~kp!RmRvmw)A(q%(BGMT{<&N3eO+i8wLByKq=MYNQAV;UDN|}?D))c zObiveUuC$_MNCy$o@r5Q-Av6payMIJ%uVg)!X1aqK9|fW7(skNaKka;*zruN5mqf3 zN$%Wu8JS*<;l$kVjuzoX^er9bWzotN8T}&S`yr5;DPH3)bXcgJg5kRs1%2`igWtaN zqjT7V#?$!kDO=}j)6OM_vq7O?D)b5&z zs<;pVVx$8lVxDEc_(=0XJqL&;C*P-M913Lxu~#}YF0M_i?5c2mmue@H2BYyBKE%hz zYo&DrpT4FW?J1hnXnno(z0%_@)O}w81#g`XV&3LDd?|ly{gfDkHwk(iZJ$1=m>=X` zBu`6M@8oISr3>PpJ(IqQ7UPa)39`|(mQH&@DQ1lZza%3d=B9|KYKC`GdV+s zP#?+~=Ta5|*kCKehS>nLBn3hN)P7TBWtRc+d1WDQTfPEsK%(+6eJ;d0Ksdc=-%1PT zOr5w(_$eTq9QX{-e9Ex+Q@Y0IWRymZx6>O;Q3~MSOE&k9?*1c?9GEfC0_EUlBp^ud zqz52a!TDaXThznb{LHMnC2pt-qo7hYT!@o*s1~F$$K3DOay@lRY7{Yq2VNSX5ew*x zBMYsxX3fY}FLePOyxfr5kI64?PB~On#I-_5F4AzHD-qvF)Fe6Wh+aZ?$+t7kR>cmK z$uid7SeD4gsFGiv41RuGOg7xk)T~<_MsT|c`HHBI5eZy6*eA5JBze4(o#$`^=qynS z1KM1E)Sk;>_U+9b(A+xip8FWHC`Vml5~mIQ#Y19v(ZuyZ0$LvR+0B;D=X#@c1>KfK z1mu{FcnovF??4MT08lQ-nI;Y(Fl))=pc{OA|-~0pPNk5Kt9|Po!5R`PlTw#RhrtzuM zJy0*(g}n~|v%p5zBd3eTzK;H9SQVeo%&eNr)z2S#jCj)E^b!EwVTK zgT%DWT!>(^jRUuByy;hMv_)uz?tefT;U5d70%fF4NuO7L9$rjh{(Ucx$1|9-=2o_N zR&lTkI`e_W#Nw{1^-zjZ1v|&=kwT1+e~kni(wS#7RdLpHpg<%97ix}aW9S95p)=aE z5lv1QHeWH*@I&J|78(0XLYnq3JTX93GSKU?P=KYgP%2ET`JU7J1u6)&e$A z>6rT=85Hq90I`E|AU4?NJRL6K!h~e88%w^#1`1`#v56$eKEt1SVr&tn>abT@SVRW1Yk)p7DhI6BR#R)nNTLywM877) zpZ|zVKvMYY(=JjFpcn!Jb`;uo`#{Oxs7UszNog+@uHJOLEcwdvWiyvXwkyn#Q3#a% zdH;FG^11Q&XP;xot(bW&%Hs2y>?yWv(GGI|Bmo|TO&`{^CN_15ch2ka%0Rl1@W zb3*#!1l0RpoN&)n}tOpkipf{S#iVlhftqAViDN}_9dR2t`#v_uI{l?Z-l0Elg zU`}h5ydF^Izv+RAzr&-6D}#@l5qf>~#zVVrZ^f#T_6{8%q(!hQ;t)$6dLv9qh{H^e zn#*q}!J`foB!M+N5<#WLCHD~LQJljtd;r3`s)x?mG|-}}mgl~sk$LRcl~t-Bz+=$I z;95r@5**C&)!CA8Ag9FVj|N=Y;}BI5qpE&|mD=f6^OOpzELGiq;Kwhet&7&#)K?|- zJ2ZHWOTrz)B8_?lpw$XnSIa&;$9}kev}WyZ*ts!{^zJY}sa3&^l>J+1Wo=4Dx*c}Ry$6HC;ho*MNrM{~R|Ad`pQM8d@JBk;Q<&kgy-nTa2*8Ag z#OKhbuXS8l<8z{Z~?aT$^R5!&rqg{7Yhbmi0m!n2)JqWD{7++ zlloKrir$I5R)K!XCJG3FBP{S~2`Oa8W<-l0!_N%;wUfG({1v7qeGdhL%?4(sqy*-R z8vJm9Zr=j~foS>4nJW*8ew;H;?uIZvjDE>7kC)zx=QAB`j2d|SCT}}zlsGm3z7-9N zG;OOAuLpTlK>sXr)4|GXJb>Xa0w=ZTSDaL@LXdZka*%cA>)qqcWw@O)^&pO|R^Fcd zB@O>#P5XC5_wR8Br9*GsUY-}JsViV-YxLEKZ%DcH1w#yDXYPO8yeTc>aoP>%WZ^e9F1)Ni&YPc+%%bM@J_$ z3zd%WBtmqB>Id3;+W5_IqfV1vBNa@2;@RG#YJr4a z0M*N(=OZvn_S#J|=rju}@U+m%N3)&OJXkg$(~hEt#4=D-UN9;9AN81t*i0=~5Ty$? z7!nlWjq)lLSGkGmjIL%K9MJ7MSI%lXr&PWFJ|^4uh;S0Q?3 z0qjsXgS%B7?}G}Msl%)CCvvF`M0Rr5|@b-;{0DU{Zzl9Zlx#oXlYWJ zE``FL46UvFMwABT9Rr#fgfJ2Bx2c5TYzcGdhizun%JI}5P749GZa)zIh#~pVSG?g zhgxBaW_Mo?u|2YOG=3yM+U*9hGwpwOn6f!_-|%am79ZNjhVOF&^g{uzP=u+QL)D={ zT^N|z*l+2fxGBIAQojI|tBOxEE?szf2;} zwt1jZ0=OUh0mew+k|hH*5K^sO`OeeSP9TOOA?s4O(MWi~VZ7cvjgXLhS{erFp;aS1 zIh@1Se~}9lpd#@L?SoHYQ+s2G>RqEaopVAq%vHAUX)DShV$p60By}m>Jc=M_VT3@d z2`1bG+oRio3M;typI3G!-Q7XLT6L=D$@_vIUvdf!cl{SG;u~W({6olEwt0A{BDTK*pPtERM+(M_=j9Q|A#BXN zqi>4$lOP2)avRNwht${o?HyxfpK$1k<}4lbrH9_l5nL4==tJA652+yRtNO z>Ox3V*FO22&hxy@>7B})LX1V{+W{jOMyI$+!(2EeJE7e7EtygCT^rE8{EOa~NqG zlU}-KsiOZmZ{A)VsZUoqe@31qRyXYqTx%AmXp;>l%1R+VpX&-7Q#?A(ZTi3BacagQ z6IPv(Y*CB0N~&J!IGJ+dc44t9%33ua{vV7M0hnV9*vUxQ-8uLF{EVLe+WBARKivat zOl>$%eX196*KuhzCWK*PHHK6GJoquIDQHG5f+?ZCP7b7x zAHS)hA&MD-2Y}dWl;061)g|%RHUju^5u>8qZNK93!gxgvMvADwePN#(#0?eE7jAXB z8Etpw$;|yEj`YL_<6A?cs`TY3jGNuWuzxuGO;UoJSSr9<@)$LF=IaRqjuphIk@qxbZ? z9uPNx&Ri>dnJlQP0PZyqpi*0{#;Dx=%ib;R8SfObzf{NpL*<~gp`EJ`8+~PD&U@xM zq3=gy4dRN86LgvCbXFs%)~%mZn#d0zT%XSay?HB!8|NTMO~cd{BOXABR3&_)`_ zQy{o}8S`}wYRCX&JRD9X1>p^A0jFGpG!frI?7}G=c{*qtl@m-6j*-s!aB__w!@(-2THlx>Yry zjDmVMizpU5CXvQsgo7KZogR0g3%LSEc3vRMd-ok`FuX){oGI!jaaC2P$5OTsv2-%! z9Tl}goprPhWhy7d87d2{@pdPx3AMD6=M_p5CxKd!wg!IKOQmOv9U;KPv!@ZV0AXmy z4^4@XA}^90m2zw|X3!Sy0f^qR@1I5ECWYD3OodJD1OISX_tXqu4i(zfQQqe}__X6p zz|KmYu;!g22{nXE+{`>{+aDz*x0pX|C~e=GQbbdu>pG-z{5O*l#F&V@TzF{FDp~F8 z4nT1;A7xx-kh(BD6_QZ7;+P1b_mW#Zh1_$0e~PW4ThpbC$FW>iPpEuhu=2F;s2kep z2?4~gLqD;aJd&YGZ<1u}rfDZ%EH`9avD?>@wqpzoww724Fd=24tB?t%HOWY zYqAEW6EonFw9zVucS(cm0W2oIm>Mwwd~ju*=PK2t@X@hIAS5MUOH^S-4mNcDg}?D- z5Q+Hr+;0AI=1S&%ZeS&rsw0GMH<`h&u@*llL~9ETL&GFIk$jb?Mp$^CIa<636+#1> zRV8Qu;(YcCiGZmtvy#zg`yF|TxJHEimB*#!H2cTqTzw0xeEhd@vHRmg;{BvKABiAZFwSm zLGHI2CJX{{=8ZFHo##jXUVr6WLquz5Fhy<6%EgPGXR`|f`?;#0FiT|7?=O^QhnjC0 z4YDTNzFCFzR!I7O@X}7xs;M|}bLZAWP}OE>1|+1og{(C%OJ;_A(>L^6s0R=PxiOf4 zw3PjT<+#_s&ii|4R*BNHC!reF5CZGmytXxs#o&LvCY7TK6B>CJ12Cp_&oz^`J(BXjKd$>N4%ywCAKGxvj%k$3?5T$R zO)8E|q@N#S30f0L%W|8&_wwO|HPCHo>$V{)XlOU7ApnoJ^nYTDcu6+P{3?LbptBQr zuP{a78eZ2g8Y`axnLKyhZ@x;f9(~;6rS|sP%sXN)VtPIFt|cxwP#UVhZ+oxVWm)&f zadPf!m27-4$-r>aEhp=P+2rIeL;O4RWZF;#ALBOP<_AuI@nZhWfUbqOX$sH7ZlSFo zfFv{a9>E|KzU1RNkV}jxWpbM^d9&(V=;3X+ohQHjIzaT5TYC>hs?anYOt$7bMzK$M z_<1Vou4&s z?w<3>BT0h^M&deCBls~aJ&btKq9;|IS27ZEZYucdU&d$k4HoZZ8Gjy!P%9LPSRUZS z{`e(N>$RuK{pGp4uuJKP+Sr&njn)dEowkAQgf^QEVarsSrvGoBgg?6BfjW?tgd{3M z7-x_b1)9rD&n|{sMb6L=?{KgRkpT+;VkN{}m0bHKcqBU01>YPSyxJJ>h%57zA6U;X zOTMB{T}Ghf1vQ9MuPqFxEDcd8VRbTe*^vEtbSo_k@JNc7<=l*YH*rdS{zyu>B&|HN|Oi=@e+nWmf($3`ldwy_FBiMi#~A zOo?;7jY#>%X0x_;s<`>S)cF?Ru(ZCfmZnB`2gSmwSa686+3gNQg_LWZ6*0-Bn(>bz zrZHs}dYPuTVmq>TU{I}CrF`6v)#tczwJYe~#VTGD9yU$6-Gc&|cfjQeBZ#-|vgWF~ z3R1a_CbV4 zWwpJO^-9OvTR!oFQr>}VCKva}TP%gsuQhM6Ushs$8^n_?B|eN}{=N2|*0!wB`cUh5<)sa*QuS6ww@BDF4e#S)4@z}3{kzN zAndF80$e2E&AW8}+Aql8So5M#gGJ*iPiE(q9<@sB>s+nhysB8|V)PKBII7DG_#UDj z%_jy>#$X7Kh#=;;MAyRaFCbUw%5M6SAFT}Kb-`2n1gLrt{)02sNOu7_II zf7?F^b?zpZ#j6B+*Zu$?p#(vy#M~IJf-$}||EkxdD)Qgt9Kt4cjxFd*m$k8|PH3I| zLHi??8bGgDBM()xY|Weo8#BRNuHf1c3thV}_alQZ|HtXf`~9&%OM#L7l)V54p=Y@Y zt@3l%H5FZb$v+@B{GT6+K49wH7ej_F+&rvf6n~-{+0$m;wG6Y>=Hd3F)5cWrGBE&0yfRIF5r{o3aB{>lK?it-zEDVPT8$dDZ(N%#G)q@Lv1Ps}R`x}xog^BncxkT|W`4n&s6MDzjWsm<9a~P$0MZ|-t$lOi8 zO{=Ar|Ou92+mWlgZfcero5 zV|jm)(6E+FufzTEr1fzCgSh;Zi%6oML;zn}$%sB(7XrddxqV3}>eoEgyU7 z4?PGwY|x*(!m#ONGJjyB(p-8>`3n3AVE3y{C21}^yb-0g>5XwYe)LTvp?EE4NKEL^ zt!*#PnK`&mJ+R=IDfBM{&BG^XCfFU~9A5ao))NpAImNA)OtrV*q0fWbqNOfLpP;U zQspajBI-rn2JhbBnzBBHZSqbBIX5Hou)1$Cw5}yf(kd1Oavjux%%mY|i`+5-QS=R+ z*AE)O_l3T%i{4Ao>}GRoJajmtMQh$!5$yGAc1QZ&R*ke`6NL)dBka71q4c&mRRT_S zGQ&eZ=;QEF*PVzIIC2oN#;=M5r!pR8z19PC^#mStdi@J*MSX;+&FzqKuT;6ePHZ^x z5~=22^eeVGx~ELqJio1h4wil^I*wK@K9+@v&b7%B$gRg~3EBPR)*rIu2|M)QrLEnH zOIJk1k`4jYVgZr+CuCw4?TL}T{Lxr^vpAd44T61#>?uXX#e3v|KpYVc)?*IiGJgf( zX{(bZxi$c9abYuuSm8X)Cf~lReW$6VGus2upt1$GCbta6d|vbHl(H74rX=*+w@U`8 z5Tk|Wi}SNJX_Fq$nxxD*#jRvaog1E%khJo}s})pHJ2qdRsTTIozDbw7-647T^V>c` zIpC_A{gIuh6icdO;+^leE>oY=s(5tc+Rts;k8(41ayl@#O)6edX&oS7Qd+ojjRIZkc{n*$a8~0*$ zHwLNV*SJDRVccGSDaO#RK_;E$#F&vLh(!j9)gx_lIZYD`;qya35wbWdi;0FtcdZ!P z@nmw>v&$jr6AL?u@sNDm%^5HY3NU$_fC2|M2;Z@|S~~WU*X&Op+;G`(0f~Z;Vvm~& zop@h{zVGL*BRSF*KJB%;6J6M&(Ac|(C+u`TFZYclQH-*BBIB<#|CA`77$*!t5XBT= z;gEM$tPFqqPgll@HTR_;wD7kFv{BgHSQN0Ir$Wf=IZ*Et$qwE$kWF#J7{XDA#|tu} zUck6*JmqN#r9O6PPLYTPI;mQ^juCWe`%Y}`kBfe_k-=7TS{t83HSa4~1=G@2kNDJW z>cWZwA9qUOu<}sb-$1Z?k6xIuq!f}q@ey#Z#heN%-GN4Y&`XkrX2~gILu;-`NbhKpW;4G!erNr%QDu%(&SSfUNw%)?yO*YSNQ_$oG_mnQj zaAs8skhwxq6)MzTUCmAHv#EK#C1am)IN3vXO90t7w2wr%fpO^52scN!R7eeOA>ijC zBb2HI%8Kc6Twh*g@KssPvBUgSNOlSA4j`R;thjE)*3^4yqm{JcG|Ft2mL9V^@^!r`46H-?H!CuFdfJN<%O5kj(17izN9{+Pd&R0PxL? z)ywumNux;9seips^?e7=hog;e&-{*_1VeHqr@!&SmBjUM44gmqnE?5Vv4tbYU+()c zG-`&>%mdDK5QR>LX_H|%q=A9v?|TPrZeD)1trx1MmOttz78Rj=d7a|s^&aKa@rW`d z)eSpuTx#q@B-I>g>c|_rmR3Oo*XVox>UX_R&Re6)Pduq*$msi>zTE0|rg+RdcvGz4 zY3Mm}_#uypN{-E4*)9c!O5Npk+Pu0#YfG!t+38YqbB6|vKKb1q@x<3YlFOmb&)ZAD z?nu_Wfp|<>ce_ago|DvVizP8)B%oPUrBSa-{nie}-7S8?5f0+K!+>dCg&7CbiY^sl|h0z{Zic zMY($)qS762pt|Lr*xlvx+SGxQv2K;X=wNuDa1M-g{=6@yyCb^0fvN`S2wJnzCbOWd zQxaS$RrfWAQrYb`+-R|m?U!77ak(jf=(itpc>J&!c@`yonK|i-U3H)D=(Uw-9G61P2}{ZxoRf zR_c&EC~vM#GYd0RtX9Z|3}VNYy;zL5(DD|I;3QcP(v)rIZk&DkjfduBRV|evp(-Ii zIzqC|RMkv3Q7sM_2TGxNYh1CsRHcw`;-qGncE>o<26d|Z#x^!z$5--MCB z82gEz`RMb?9*z4e>E@ydMF3Bn*OS1NNd2kSw1ls*2T-}+UzGdMZ)Geb?amovLukeBv zDQi!ke#d4McaJM5ljjDMYE$pAtxT^hXp}1*{5UH6swLd5Vz&g4IkUd~;lJ{ecmKr) z=`HX3UV7*!{x0411OJF-?teEGbTF66dVtqUOa9Q~%>F2YUUbUyw~zdu)P+vLol?XC ze@WQ?-H{h`qvmq{r+)13{X@wMlxXC}*Uwz(gH^M9e)iKz99{3VTasyIbk94!rDK~) z_x4kVpWsFVk(2?@pfNH%kCZBC6dyw{?O~fi^hh{11^~kghvBD3xQF9NV%qtKT9O7sA@yIuBn@+hq5K%28eeAbC_V%Ct-uJ%$g-6caJ#4Pe zr=5$_+I&4ll{b(DzFD+k#}YrTS1wFj(%k(AN#8ejc9QIUk%sFgzwvE<^`DjI-aw_w z94SegRCkT6a1%8|_RiCM5yDjp?%&*FMQws^J{O;l+ih}Hmh7~^p5Ndaf6K#1%C5`(}vtP%Cl9< zs{kD01<)*32jWIGB&D2?s@x&6fdmhU5+`k9g-x(1PXgr?fg=2_<#po8q;U{X?%*Pr z*QAIOH}0lJonrtu=4Vk?%L3RkGR_b{BVH{C#}f@9NcM(E5LL4v#!)D!Q<_QSrQA+O zYX^>2I)+`^S~!06fAXT?Kll?r{4@XV_g=W|Xs!H2etO?<_ThU+y{udR&42OzFSw1R zeQ|^YHHhI**jwl22OQF!x{K?#Cmw+FO1o7UStJ)wh)+bw$2GGomeEpj)40F|QeuEd z1tY7bwtn$LZx%cK$rivld{Z}R(2B^Rkc|BW8n|w;Gg7?+_fHp$srhC}NnF(|X<#R% zdU#$n^W>X~pU4`w5hJmay5|dZRr3*sO(b(XXoC|`9(}>eP&ItfUvZ~~umE#n@MdW%T=w}Dc0I=e-7%;D@G+5`gn?dq$7Jn2&p6~!8 zUOb$gC&9iUMQundB!Ma9Ol@|CoN|eu=#=3aCUXn5WcBjZTI0qQ*@W*mcLpesoG?`E z_OnMSQ)#*-X&*18mWy*BoN2kkApC4hX>WIgv$ZZFLw z9=`x!kO>0hF$}z<7LPi!0DAYE$6No2#v?a#>7c_;L!-{J4`#IK_+3-Y%FOXm)apy| zV9>19O+U(X2^(jLqe;gtb;iRtd7K&{oM*N~ zshFMw%?yC$>HsG6Yz)TahnJ)=uM5ZB%1HR8N&xbBNm^d?(6Wof3)F~J>LEQ;u~1<| z{WWf88)GO7d0dB139syrgpM2BcpOL8=kIy8wLy(*8(?agN<0l3`C>G>PTj6tle6UB zkXwLss~7}9BHQYWrB^!J7gu_=r6&usQ|GjFB)gqHju+4)AN;h;9Wt7(ZdGav9gu(~ zDzS&`mIAVF+aGehK0>ELf!6_ep+R9hLJW)}K;Y=PzaYN+lZ|hL>~}XL31PbLrgZhf z4g;WE>#A8<43uO|6gvE_I%LJyg_k(pqhoW)B{;PVPgb<_-@}&gF}RtleRa4vh`R zVrJ9^NG3TLg83nnDiv`o*$rvf+@Vn>aVJJK?hSMO z5u{9eM-TlGMWbeJ{l#BarBSnB)heHo+B?+R>XM&&Dld#^A*t-|bf`iBZJZlW*c;aR z>sN2X^$?AXd&zDoARjsXEzhQzj$~&ye2d0;uoiB-JIijrO-c{}CsN{jA$-LYH;q^Ej54*o-H&?LuPl?h)#9GD zjr|AaGCu$VL!rw69nYZP1z$XbevmbQ&2#JmQd96c*iljf)SZW8@}yJv32S6OAiG)u zNZ3#@^$VOzj}52MWHyPkyg5ax;TVz7W zzT=y}^-bfg|7gSZerNOI_kokk9`Z9@#JY-b-@eu`BBfcD<)|geNt3iBQwk2~jtW4o zd407}mUXGu@-haL=N!H7w`TXxK583)1(XfM1o5MOao;IyrldjY@WAIZ^r?NlNfD$4 z58E8ioaP9E%L_-0JdgU_O(bYM?iwK<+w|B2^ zu=C_K&#Osqq{?!_Yt=j~>Bt*!T{b(s9vw>=L06Rpc&zYKa2s$pUOp?8e8{$ovYH`et*C#3lqL)`cwZd9}BC5AKK;RdWx zoGgTcVU`X0Anhy(Qq}Bib=1Mixlf%y_*M<6U)g`hX4)Qnh%O#lZr^btYV4GTmcdZpcI4)VDo5u0Gp+PC@1#tHv>BXfRG$QtExpq zu)eJ8)VscgG!fj66EIoo=0OrK@JS9risW4wb88X?G{im8Qe(*=RXY`5718S}> zOW|gNs+FmNH`=-@f8w6AtE*QxbsKWTvMFaf9xqp5I}UBPJTj*gGOM!4@T`KaWWy{4 z8SJShxLXd$l2Q3|f<#FW4YXSn@pK?gsyJ=J3-Q;?Rln+;N09yeF$Q2Nx|$y2ry%l8 zB3@z`K-ho$v|e1;_b3^b;SYwYtl89CUp0oq%`Cs~RM)BH3&-F1j{LD{ZB@3k#4rlh zRQ=R@2kj650N7A3?hKKAUo@vd0t-NAp-c9RLo$*;eoR6RK(>EA)%-laXc!Q$AOvNBq%wiV z&Z7)od9!!fEYx4Doqo7KHB}?oDa;IeF*VlLuMgL+MMl6p0Aif* zLT+HN&$iL0WasAg{p3ITo;!BG_#>@!?9N{}{MI{@nH&HJMlB(JYPzzlNV@OLAW_q^ zN(L~EydVtLmxA)4qf{=+($dANrzJ&OkP8~KQfXs(*^a?_Zrc~kN{K`c94xlDiL5U| zyHO|s#Be+SHHNJCY)x4+Bf;yh24Lv|91-j)K@NC`I6MoKdkNuZ8Ki__0ld1yPlpw) zBixqE1CF@;Sd4LYdz8~+(o1zQ(vBT-*jS_1PMd~l~G8!F}T|BpPb#+kSJTlR@z5u5-YmEFl-+Vb z#!c)?nYzsnq7e^1kCD{GNNRWn1>!qyZp!Nv@4xNMf0DeAi~H{XO@CDR4>c{NQVyva zoT1kq%XJ1l!1utxsBHq&q7H7P*+HhC~_(AhX5-NzeHIr)E|IOnOY^C*CGZ$At3;C zo}S81>J*BkmVsKa0wbajccLH)sCpg}2CR*3wTcC)0(U@y$kIUK7ZiY>1n>j%M6tPf)-#>bSMP5Ahiq5h zl?was96WO)qyvWwbodR^bnxVYRa}^v9&AA(L|J6lZPKRpvqsh_jO9+p^Wva;CIvG~ zavsG<)k6mkH|OgB7>eXVGf=Nq0kCWb3=cqsU`a4>e`O>>KDSaZs5OljQh^5E=n+rj zj)Mqb6dFM^%b`F4dOj~~D3ZW@t%RHa(gAE6dfZ~ets96;yha70lQugCMu+IeflR{! zD@$ZUpXZX%KEH9Ll%F~N(IXE&Jh*zpr3iO+>FKlcaXVRy?3M(w7wV4)eVl@w5MFsL2}0^4%MmwT zA-zfrvoOQb;*YC8PmO5!jm>i0OK5FlKpP{Mt~~RJ+m!9+zE$^;V#)OdBwa?*Ykv&e zEB?xLEev#DE9qNNg799g0pD3CaT_08I5-8ssR;2%h(aEvhp-R1!rEQprL=UJtZ7TM z12aey)Rz-ge(qhk-2!$2M4lc3OH)qr_%XbY4XhhD(m-1eE^HD~Ta;(wte~R}I1B4v zc4Dx8+8a>}c_Yf;9=^~+g6WU~UvTvPkzBr%*QZWBEA6a)*ue3oAw!&;WNbZu#Si1O zQMKvN4Dtn*S~&Io)o}5zkGe56M=6!hLNJdc#AMYd)?WVD-}&R(lbJldVQcG%KTPPx)es3)0x;{(Ce;W30}x5Z z;4?o{E>U6s-FJLr^Mw!mC$GOeJF_s|(+fu*9B%bU$%*j`?kH8_ot96!&B-0W6D2fi zL}Z9Z@G=m=)X>mIo>zc@lwe7Kgifp=gd|UaBdXKDnQeaGgBaT?Oq_$NV2!g`Fs3(AO7Zs zVj1yHGsz03`Of{vwYi-S|MV{eQ8;(iv*@WCBdVX8Pm7k6)pNN0fNXI-({0}*oVn@6lYiW}3j?f@b9Wkd zbo`V%x@l+Aqx-+%oBZPG(;sj6V7mEl{s%8Tx7EM?^rNSvrQdp2J?HPM7FttPW#cUd z9HzI@;{oQ2j#Hb8yAioNLr|fNBoUg{ho7KcEMPP+GtV*Pamoxz*1g;k!2^BxkrJt6 zBSAC73P0S1%?l#7R@N!p*#Xg_ESw-Oa>%geX-H!AA$*F1kW`1e=^0bd=(zGavak$Bt^>@wKN)kr5$3+Zvh9UP5^0m{)N7*}eO9GiqHmw&Dgimh45I556 zvGzr1<|SVhTN zkRJS*cVATwUf6C2w%L2;z~OrjP^Ieb-+up}J9kWb;eQ^i@bo9}qGurDeg+yiz^y z&m-tEXzBpY!;5YyJa~StglodhbPea6ps{5-_5v=l-}Aaf+kfPEOHOt>b1Q0pnGH zHgC9jNn5uNk5%7o6waa{mctJtVA zz{#sNgHQYDGEAHk`9hx`-w*WQdyd6=Xtew9&gJd#gcdg_Fw=b zQDa);`_0{8e}sWiT*s;Rw5k(=5Xphu9Yz@~U0jN@(dcsG$Eg%-f47wlWJxi90lT=; zu%A|^H1f8VZ`g*NS~Jy;6;D3=Na5}u{2%iCfySlKphjVio_n@O*E$OIpxGPk9+25)bkg~?uTUi(9D;vQ19GzTHgpMw2G9?9ipLAr(|YP3|OjERJ#41 zI$2TsEY2f-`QyL)tR4>Hypvj$nPc|>!n623PsV=ItO6vDM5bkD`SRkkR;Aig9E-G_RjQ;uMfCf+JvNOTPBSPHg$P$j;Vp|HM-V_sSAzbYcgHlN$Ju^BF{8wkSkDl>>!WL8Tl&bxc(H~&Sceqc8CTAsYKvSwwn z!?VxlY(4F6zHl`u<-q#3qZ;{zx{;epW~NM>B%(pkr`Dw|1#6pk;=J?XV(uZkWr2L~ z?zg?js}F&hAte}7tQ%QgsG|9bdv1~R-Pt?;vCnx@YC)2c^N;+b((7KkW7ry5707^6 zO{NYMNwG+EMKc~k;Ai<-_R=iPxK$c{Gg`AS7}_DEKRnzgL+_OkmFIm+c*?QL4P-w`_)!u&oZ4x_^t6bQr8zvf|R%bcS0uO z13&qn`{|IYiIsu40Wca3DO!W``P`X;bPnghs|xW0A7$uXz#eQUA^JE}I6g;WC4A^> z{vkib<0t@7fVz0-00ljqe=q>BV+5NnMLxK)}Fpx;2AE{`w?Xp_wf)9GD`(iUNn{LDZ8bC97pNn zX#yZaTJTa;xN~4*Tw~m5?(LJ26eXGZL%W*`11V^4bicK-Z2=|zkBfKq$A zO`#X=BvCj(sU1C0G^ud-o2YeuE*$psg{lTd)F}xiIrMzJzjf|!r=zaumX=Wm*4lm} zB46exAyLrp6F+K9tH@wsFf#*e(59%j4e+*6kuA!+6yS9MxD0YY7M&nGN=~&Lo)Lp$ zG#X($Yy`%2h^GZAJoZ}i0XUUR#~=-8RV>~Y@(RCM#VQ$v88`&00U+{w=tp<2UijqK zBZ@qi+J(!yt!b(rACVnt6W?jMys0bDP3|Bh^Y8-PE|{(q9V2e_$y_K=?Vh`u`m9Yx zF#0%0qTS26XY7^+GMMEzqo4^ugUJxec_<67)+vE7z*YPK;We6i@x_mRj#geMEK*)A ztw{sl>s}dw|Qse4S+R#MS~?V7h#zOb*yp6;rZMLvEU5 zO=?L9{~lPImzr&h4iKj_g9yQ6NL-M6OuT&uMyhF%ap||GcXtt1*mqj#>|B1M=Ers^FC%o@RIS;hfo*l$Ntu(lNu42Z;9T8S?e(U7wbo|KMA*VB5|j4L2X`&y2_qwyfsK$`^6vcq}px zpeL|nfs#NaJ6{EJ<^XckRn3tBa2NKKNayw8kSL}B>(de$W|jCEDH)s-zDUI>n{ve* z*-nnE+%)Ctr^qn!6mJ2*P6bSEkTX9k^mvBI#5n>q202JTIf57tuP$U#zxmR_+SMoS z$YrUkfw_x|2PsOjH{&`LaafUS~6xkHEd?d~E>%3taUDtXqj1>2A%*UhN!W?3-m+!gx4|1n(cH=J_eMxx<+i0Mr7Nd;IG zT_djeR%bw^Z~<(V7Y;ej}hdh($9CyRV`8FH&n?Ufh4AD$owA(BIVil4)0Yz{Y5r!>D-oqiAf>n$)2NfuXlFA4@L9g^DZ8bC%!V8Hi0J7`d8RRRCJ#X8@5TRN5S6;M@HtbT zvh3lh?X%C8_`&);Wa*h7Q6D#{1m;p>U%Pg;M_R$6jiHRrUjsCnpFd@VuWCNUV1AH5s-qGm@`vk@1o(_-z!OR{zxi;Snco!lLt1_6>14RJEb9R(hd|Z1xY^foJn1#| z3zh2Ah5FpHr0P`5$&`m9SjYhi^GDtSM&?u9Q+CS(IY|1SOu_*eipS|Pe3_lWVBFNj zaK?9*sl8Kd{0AmGj&sZ?*mU}h2f#E#;zk3Wo92)DGa=a2v}Y-^i}ACMKhdnu?)R;O z3wGpoN*X23`O;0`2T?k;<#> z=ebpc4+^LQ|@8VghMGA*Q zk^x|D-Qaa6c;HYT+FSx?dEuTANnSYQ^#O6wbzZlFlS&Oz2WaPYG5~f3?8viFR5+^! zkr5}55jbyu;FDfPJ$UsZ5sO%xBTDGZ-mKl>-dj7kASee8I2^6WBa|QD?kakE3Ew$U zqh)5Jt`Rl{G`%km*L2GJrjER6^=y+yE{+W->+gPFZFd)#f3`d`bx607keNK4NTO@o1M2zEfnl~54I`Sq z`vJeYu)t$~dht%P-hKHz4R+Qkh5RrqgF9QG9=I{K0}uujx1dq6F+ao-p;xdUHvaGjHHbn z)+kv7oQp}S9)K2idvF{mXzrZ{mih4yh5fTgEFmXwELJ3}>3PnTjO}uBd8+KMk~P2I zKz-YxV+F;jV$$b);nV>|iakZoZ#*#!GlHt3(N=@5Ki5J8@nlKrqhfo@Zh0V|I5FP@ zKoWx1jAbwq7+A|jD&I{p85Io3-yPY1_vzhTDCm!z&CMHXeoCYAbRLWiy_#gy8-)~& zf?OIQsR@I}^JX|aaL@6t$&Avy!&WHD-*+5I8`xIm1-+5^%h0flRhb{3>TyH>lLDwb z?ik1s9W*?Sso!XzJ~0>&+c1z(kH*=SScpZe&A|y8z7vbMz!77=1NLUU&!~iKVaLQ4 zal(@bz^n$!$nkm>I0nGyGl1a;EK`Co6fHUJ%^f(9AYzgXa5%o>K~)Kkk#;-e1_K_E zw+Kd-r>773wKv_724+I7?S`3T!JWlB?xO=ob5yG-NT?8%~&lg);!wDBvA3 zg%>^KL!zF+FeW1pc7}Yh@Z5`Oheoez*B~hlxz8~{&sE}fDM9vplDK7I&=!s!V-_R zRcsv$1!>V7mVDO*V5iRk;K9WgU;b#nd66w-J_?FBV6sQn>g2hi1H?U+DEc@)O+j)J3__x?p`twFm+kC;-SDfYGr5 z<3wm&Rs!5k%#YC>US6Tf!6`@+9GsIjfRYnLqh{ z1HI1xCSH37c}JcWk3SH^NFrMo8+7#oq6g||S1r;jW~AhcJ>F?A*`5G$ z=x+W-!b{6?$o58p{kSy%QkLl&49H26Y9GGsEud50K~jS50L&8(`#b$?(2r4Z)&zi&b}PLhJEmvHLzGN0)SCfp6P)y!07CdF(+|i zE13drP6NxA2*nIsPUjgE009Gmm(k)8Uq!hibaL3;Jh8E~Zp*s$235DEP7m8yX0{3@ zUU~XM2U`GMx6||LW;W8PyBPRhrUH0IjoXg_DBMe4`N!wMJ^*4?fy~So0V3#egyq=f z=SoAcreM+jkRP%H2IM9NmJj%V#Lo3xGG_`x69eHQ5fjt=!yzvq4hP@`Xc(ymq=m(f zICjp6r!nClv@`Gxy^Q1az`7PB0W`R(;ToWv1jp)c2V{cb3FpH-iR?n`4k;&-xL#JN zN|gf#sMq76%9-R;_rFlxf0%NMc{qog#zAy{=Hlvs&HeQeh5eL0 zVkCVxE;J~QEIsl4_~jeDtTPA?s3in{M<=6@C!Jdyz;gVLnVA4u8KGJ&@RC?S7LLa( z$qXx6Fc>-px9RefEYt@e!`A|A3b$CW&gRg8H(t}&w5RcXK{Unjm;9Z^i+%%moY?WP zQ%DwBiOJ$e55qx?8ji!Oa&lQLD^N!Tx?00A`rQs3Dk5^ND&;h5l@q{#M}zJpR47>3 z1*{HWx8uA(#liU zP$y}@K6K<)^7F-R&jTgk+>EMD*ZPn^<*Bhm+e5Zzfb50RC;gxY9N|s_K%4|I%6PaW zvtY_iUrJWyZLRY3up4Al%GuQFc(i!H29wKIrVAQ}SPF2Xd)-yrPds0dhu2as(W3-9 z8{wRnAYdIEL0WLHnqdK`aF__d<%`8Sm~o*a#7h9QWI;RU@M6cp@2Cob13xRoMZ(9u zeBAcSy@B|Ro7D! zbAG%p)MRKXba1)Nk*uuB? zKlmAy=I(8U2M=+R9e^vgVUXf@I+8pFCy$2Xm4u|&qv6t~=+WaA4S{6_F1C>!mM&Zt z$`?&|0bcAFU~JT1f-@SHQ3|}#x8dLbex;EVRKg|U+Ju{%o66vNlr0{chdPpIu$7V< zDbybh=0s)pknI^DAHMyeM+0cx7&nxAu6XbbGZ;5ragQ3aGq{Uva_`$-eaSZRxY@X& z!{&&1iQ1i&E5E&O)(H5S(&f2ny1qSvv{vQj*3wog$w?%pCxrwl3cyGJHFq+k;z^?^ zkD1_RGai4;EgGC)a+@y&lp<-$Wil1B*ueL4281WRXeNt! zkwgB>Kd|9V2Y3^B3MZBPY!Pp#K?#yZg0@$aYD(M3H>1KDRk_$CN;*7exBpoGSAKUwi6KvT8U#Pp0`JcLv?7 zmo0kkx!sEG4cjw7E|vDR!#HV&!3Z~&0m64HhZ~Mt3t&rOPu%1vOOHl=oTVW&ACkAF z3mwWK>=yIJT`#@VbTl=i#k=1UIAF6wi>kVC@}Ym|4kO+5I3Pot;-7#XfW(?qwGB#; za3$RA29Suzj$16+vmr+kw+GPj^G-Mt;|J(#DU872NY+YuQ4UiwkBZ}W{0F=tp@h@K zBW}M1Z~=S)MBzXTZazMAa|nkSjZg>35C%N-JI^FfxmOcRt%8lio=cho04qF~k&iQu zRDKt~BZ`>&=P7Q!>})PQ?+ix$bm8PI)#o(oZQRIbS-oKKJOwhTek4bgg*xR6`PROJ z)4wU*cOR0By<>X@$ZWEtJ>riBs5EYH!mHVj+gv#;i+lzzMG6l@LoF%TkOD~bxT|7rdqloukv>(_*DpTv)p0DFUe9%$(s9x&HCou`zz;Q^I>s)z zXKYUa*|z3?X*lE+D-k|-I~n+L%L+$9+jNlyDb(}M13Aa8SPIGj)AGQJYVi+j? z4SNIVH4w56-amh=%NLojS zjePBVec=GzcxIL6j#TKtu{%aq#c}i1>anetSI+4M_FFhXO2NTJ!Rde<_6jraDInkZ z(05!)CC%fRz#>L;*-y#wcJ*W=gaZ?qGGV=ZmOP=AugOvDtc0%re;WDC#A2x}k4PfkC z+oH~m4oVUpCZdLz34*#f6@%s>VdCu#l)+BTOwI^|8GwRd5K++-+C-??8(<<#h}bh_JTH*sw3)63UyIMC+2@C^exhu1A{dkV;Gik|zp z@AG5J00zhj(W{4>DrtEn@&o)W(foXcTa&j51vp8;^fj!oyJeDCE zcr;k%IeDlA9F5r$l#O=)k%2Ag0@&Q*z$DIi(QsT&x|q4S)q@$CNA4wqlx&Bm+~L>^ zey$@-GTploLj5g5zhhC!V&d6(BQsFdq`lEbdXivwF|wm5rsDxjdY{jY_HoTGOA_#&UFE+LJxb* zu2`mkfiL5G(HDU_V*|($9{mQf5)DyrE-NSYOHv#k@j@^)(9KVf(j0|~1r5hQ@vMtc zQx&I;WL3OawnUIhngvG$%k!9Rp2f{;TxbP{A~>j8D2z2Xw)I=f_gs5nyS};7r`z84 z&QH{)?)>?!<<&D`7DJq=rlG^Zh_1TQa1_2Z&ZkS*j zaij2~Ak2U&^=Za02B@u zvqXSQ;T}G&B!_t>e4g7kUOr-+zzJ!Bzq!7g6w1)}O+*rVK2K(eqXeHX%PG`}pSQqW z>G+$+Of$Q)v*1s@03r$ICqhZW0Z|sSJg!sJzh2iy?g4&sal{k#h&q43yt*1Dq^LSbwvljiZc@7e80e(;Xti6yRrP&ESkYcTY4u0?RtL@S$>CCH_Bu z&tQ+60}7YgBiMh{D@OK?rlBuveetYWb}J36=&;j zOvx*DHn+YGJIv$7ZG*SG?KvRN&K_;X5idRhpn$y+PTwRjCfFEh4ZvoKK{YvNnle8i zHD7U^^6Y)#nrp;hD~DMpRLRVhDe#8C8z9TgS71zTHk0{bG$=#%kvt-_n`Mo}IN%sj zAna2x(`c;$ka9TSaTO^Q4_6QzIP~+F2$q>(N&FKXH$WW!&0{LO z0NOoY!fvQHFU}}Dgh8K*w&HYWKtZ!lrjy4W3`WS02+YmG8v0r>ASrBsm%+~M zz>coSDGjy~Qjw_Q3!EP>`jS$is8I)+I$zClD=+@be|Y`UZ~jOqp8>eKXmkzqw)cSIxZnug}(1dC^bNVu6|QfL%X=VDJVG_PmG11>lj z<7r7e)>qRMND_$*0LtIqq}c0&p+}TJr)pD(2o}J{&INGuJ9jCwjUk6)!a1Bm6;8rQ zBEIErZ{QQIAbRdJ$QGk)fKj{T^%}xKGDsUu19&-cO!2$vkX}UHP#ik}m&4^V=(%M_ zht`NtX39|{3IKeIW>3$PzY{<*RcU7WHaYEI%iAU#%h70r{KC%VnoVf@G!74=x(>=+ z71h4QZO;LjP0_Top*tEg4U-dqC7uS#@&F8-fsrjRu04g&9R$?hSr*|up983{LmIl2 zxgMC6nTi2{ThB=zCsNR~+$NU{J?d=(a=dV%2I&E|<0Oq+GYmunZXV-@zRL-|ht>vU zM2Kg{lDr5`#4Q!fu+Shv&vIS4a|F@=4kM0-a-eJl@I(U3O?;353C@chQ{gcl03kbr z=)z{lU`@=D8RsK8GIiQGevw->a4dKNuXM!ngCmpDmB$O7)9HJUAl0cctHHn5- zFA63%PF47)5k77SAt%~v2=DpoD9AixxWQE{_9pO>z}J%Wzq@y!VmMP4w4 zCxlXn8xKvbIgl-4KoOV{2_~-`&zRv+amk1iDvPvA0cAk@LZ5p54t1|WYRJ60KK6rg z+b@GW1E6D;7jQJz#j;`}7(nE*39N9l9s-2M$s1x$Z$zlRa4<0*1N-n$P>u*m#%1SwK{(1CBDWoLd1j3@->-MNJYMjJkW>R zZ#j5#Gnb|VT`C*_m{(2yo@EX2+#b-*4akgO%)a6UjEZ5JoN&QOGO)QS707NJ(N(+cg zr7A^8FjE|p8^l#RNBwpx)~1tEkAhJL=MVO`6xz8oAh(O{P-m-ld;?4z;KegzZ+Y93 zKz3#Gqr)M$P)0b-5@INxF!4#N&%7-Sdc zY1E7X+%gzGqjG(Y>TkS_N{3JIdKfm!^d{2c%VZBc@b!J&98U)2nH$_>g*yXa04%tH z+zP-aR-Zi#oeP#irq3&AmgX$lSdPT3VeY{5B4}V9?#6N=Q2`-#0>CNo@V!Re4TNi+ zPY1_fxyx%(@cL~0K=dquZsnntCfHjBf`t>XMkR3^Q5Ob1>V{nfIJs4X<&Tg6*hM6G zU{X$2#eg7=3UCmOyAlCf9`vK}Oc90a#+ts5-}zzfV?A!>WzxaSsi9(XIG;ZvAG)1q zTt|UVg9hXYfG#BfdfdmcA#GeFwf1;jW(IezSf&0N_8SIdA#qe7J4(kZ)NGwQ4G`nh zCye}MwK&UOj)Dj`3^!OLLSRb<5B|=e2YHwzA<3yN=D@@y+PRLbUCxsUGJ4aSX2{!Y zQy!9o^(a5bPtHaFFcL`IYz2UjRf8OW#Bcim(72sfFf0R;g{(;A@W!k9Akh=S02)(6 zg2t=VbAuHx<;C(R<~Cn!&%JN#V$8_Att7FeU@blFG!cXKq0?ht=MEAl?fKL{vkoFh zV(CJHKq8;1H!a4a!{OrEgxg8vjr3@#!U}XH>O2`V^yN0+lYierCd9KvIHZd1V}Jm^_i~fXHB$Dvij26uW!-nOSmwI{ER9+Q;xF;d+6UARulYuy6p72|doZu{+cYQoo-=1Y2DZ};fRxNN^XlZ68He7WHiiZ07Qje@z1z=kcpbb+7GB>)1ZlDj`Z@<1Hd0jcwe(%9 zbw+~+eViqhS09WVFyXn(;2%h@WxV$M%Nw6uo*(u?-D3vFFzEDGP{J4x6KRv~Sjq3> z+GUYsI;UyI_GMe_PqoSRS7S>EcNZ_2S+VFVVzeLlJC=7&8tj%RX)+QkhyZ`D8Tgb} zq!juMs`M3Mf)RAeX%5fI+O)g{w3d8SYpoYAW2E`Tg+F+Jm}w} zMjrTI=}xx<1|7#!t+!1PI>!K7Uof%4+2^}7$=wF`a843s>G62z8#f-;U)Tel{voBi zHL?@g4+RPSj9Eb(kkSRB!GB?)36ts^fe%r;Ng9pM(_MF3n2ZVyx?5I)Zg2*8e$*7n zwMzS7#}zTN|Jf}vd?^o2xfwCRR4<+|$lOL!5pZ?GB#+;-Y)gyrMk{f>TUnpC_3kz? z;H4#v2)!E#U~u0RiUD01A!(KVRwNwG7Sa&Jark+x{MY*E zLI)k`o0E0EO|hNF9j<$jvN?x#)hkxHYAmm`uGl-Y2`5y<2827+9#(myxJm3j9Ws`m zePenA_AQ=baN8swAQxdy zqOMMT>l=lh{(!?zJ9i_ShDVyHTM5qqyAuox0mODly@x*?y$)3t(Vba-u$XqS=VLgL zckmlFCrzjXCNIvjGu_9tZ}CK>kdbahp>^{~$aF%A{K@82MnV4j^uJ%R8<3RYIPztH zsGX++Dvh{sfPbkTtEiFBpL*AkqFqU+$fT=T*|YPX4-J4-MP`5*oI7Ey%;VqgKQ`9<^ub zrDpK6)b^8W3*XZcWMTB+Om-%CP zhwP%}5?nUaRDYr*;n%9>zQ{z=f3JNCC>-t%245kYFgX>1xSBQxAZu%JB8TH6F zu(9}v%Q$hG41*I}M~3SSrI)&n<2nb^t+g=tI@3678r>;S}gp_aC}`r~3O+pNi0zl=cR3DSkz577p! zQ&x5#{++h!AFOcaL0s0=hV7KQC5F&rVGzdC zY&!prwt94gaArWbagT@Q(pH9(FrsY(LNG~fD#mwp37>fnzmZ|NXMt)j#l~2Qiny@c z&(Zudht)0j|7wLRU;U*9@a`~bH0Hl4+z;}M-y)QFT&xGnl301sovOv_ZvlmO>C8Ya37cRxf2r@!E?3aerhw!xED%g$$G}D z49*^(HS78(V3_@6(nHqrQ=K06yF|3=>&fXgIIcXbj1|!ZMIEyQoY;QNNuE2o1w|t= zNs{UC-h@G3QxX+mYCEbNXy9gTpxGc|xM9Vly)u@B2{574Ho-9TT>qnF_C!ygZq?b9 zb}0Tu>&7W6*CMv*z`yvK<*QGyDN(-Vy0@A&5%az=rmP&!sqbb(u71~hzww8ekMX+V ztgz8XxFnCTvFC(q{4@-^*mf)aO9fs+mrzMxLg&v4r}N8x#V&%?9LV z&%DK-`^-AOvL)7UZ>3*Q9{Sxob!av(*5UYdNlGQ>1M?$`{NyX{&m#KJ=Zz_45y6%M zhty4HZS|J3H?YzErz&z7Jgnrm8sV=r-W5@chaFC85RS<%Q?#wUUd=$O>1J9Cd%J%N z3ah{AR(3eSC`(*LayBvi*;87Cw=7eSy41Kv4<;3QG!6t4Sq<*$MhrKtT86GXuer1v z%u|K4f&E7< zduvELO50IZz&OoYZjuaG z^kc9eB{fIm|OE>#Cl3(Og*n{RJUlS zHk+<&O^J4>7M5iPswNkQZ`7}b!gP;epl1FwYc*WhjDAM+l;N=-?!?xj!q*5)@s(IL zWl5Q({N9lrOL<{#H>~N20+UJtpe^T1Xve$4=;8#@gZuq( zINg{X+fbqlOZs$i;j}s8ctEQxY5E>VFCul%IACd4z(xIOE1$4hLg6F!b~0G!bIi6P zfWp4tkny;pU`e$+;P8~sFe`CiHTs`AGF#Oz@!g6k?tyB&G}9OUqaGPS@fk_r%Z4Av z@-*b5$3vFGJFUJ?)(+DVi&vw+9~W)l+ErqxIZtx#&VpRpBur+G@?2C#Csd7}b~)(p zcgqeVrFg--P;k|HX~)z%f8M}9m8~;9MleTk&~uk$kN05_T7B9Sf*8u^DkZfU)CDgK zAWmd%cP0IcJUO>$4F5yv4PBs>0ck6E^nC^Zp`=;;*OUx17JSspwnIS2*nx# zG##ZAUTFV~YOax*m`?{5ec3(pKgz3D;oI~l#?9$$%rr^cUzt=qH78PGY9}Z^Xqw?TII64K*4`a`27K8~0fg~<)!*AR-%j3ouZ|P=@ zrL@ba@WfbU-s|_Tcfmwb;~Us}rcfiEXrJb3?)>B29Y_WxYbR{Bw)^?d}^>1F8CxK1&XG7 z?>k`#MR9;ScIm#{su$$X;dpSqdb)sZA*(?68@OfOT-={1U=VU79+O~o2P2l5&IM9G zSvB+shtTrgPeBi6GVymp9~fakAZ_BNZH1dzmHd~}2NgV8m-`-FAv3Bit73SxPjka$ z07ejsyYHeBFBi71U5=wsB`%v|5-&oKu(THNpo{*arU%3!>O5kjWxi4!-pZ{}PEUQxA?}u4> zBBUeesSN|%?h>-=>a_Nr#HC}?6Adp8J{3ZFs9ZlEpPKu3tw8(dkzGaS>Zd!`w9iK; z*n}eh-v!a|Jjj}Q{1oDxm?=ktpGnWIQwel+!;TE`@)o4$KE zIB%6T+XrCk0RLj8Aqq0>s!}|Z0n3(R+h~I?!PYE{F^JPk*Ovdi_?_etg+$tX^(MS~ zubcU|oj=QKjZ@wiMZUsl+HGktNg2sA-2NzH>zrcKv9sdba`-9x){DhLWLfOcggTpU-@ivHKb+1(Z~Hql-n6 z9nB-zkuAR7H!CD4l|+TA=oh2K=AS;rxXGafk9tC;7y6hlok|xRnnHd zdKBU4U#Zl!QNBEkbOe#=l1dC(v@#ympYqi+;99h)3vJ?SCR{HuqPK3Z_r8|4sO9yn zHg4=^NgSy2caV0+pGX^gF!C9Y;U>4QxSG#Sy7y2{e}ro^iEZBroQ{>e6yC*zqE-GY zwW{hNghAFqZujZPoBV583QM#1xd)kU`xy{%@jYD3%X;y-M)2`5b_h})9t52vTHo$=%`t8!pv^PEdzHPQ!#lH qof5Ze<4m4Cxt8<)(>jLg|G5Cl!MKOiw*k)pmt~}9dbe5!5%qtqNr;>P literal 0 HcmV?d00001 From ca905a2b4e4a7f2766361eeb82e3b5f2a388e45f Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Mon, 2 Sep 2024 15:53:53 +0300 Subject: [PATCH 11/20] Remove unnecessary encoding of base URL in BasePageModel. (#103) --- Website/Pages/Shared/BasePageModel.cshtml.cs | 2 +- Website/Pages/SingleEntry.cshtml.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Website/Pages/Shared/BasePageModel.cshtml.cs b/Website/Pages/Shared/BasePageModel.cshtml.cs index c159d55..f6df708 100644 --- a/Website/Pages/Shared/BasePageModel.cshtml.cs +++ b/Website/Pages/Shared/BasePageModel.cshtml.cs @@ -18,7 +18,7 @@ public override void OnPageHandlerExecuting(PageHandlerExecutingContext context) { base.OnPageHandlerExecuting(context); - var host = HttpUtility.UrlEncode($"{Request.Scheme}://{Request.Host}"); + var host = $"{Request.Scheme}://{Request.Host}"; // Some of the strings below should be internationalized. ViewData["Description"] = "YorubaNames"; diff --git a/Website/Pages/SingleEntry.cshtml.cs b/Website/Pages/SingleEntry.cshtml.cs index 3933046..c06b304 100644 --- a/Website/Pages/SingleEntry.cshtml.cs +++ b/Website/Pages/SingleEntry.cshtml.cs @@ -31,7 +31,7 @@ public async Task OnGet(string nameEntry) } ViewData["SocialTitle"] = name.Name; - ViewData["SocialPath"] = $"/entries/{nameEntry}"; + ViewData["SocialPath"] = $"/entries/{HttpUtility.UrlEncode(name.Name)}"; ViewData["SocialDescription"] = name.Meaning; Name = name; From 4f0732b26d0a31fda6e1b7a336008df4ae31e412 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Mon, 2 Sep 2024 16:02:04 +0300 Subject: [PATCH 12/20] Encode name and meaning for social media sharing. (#104) --- Website/Pages/Shared/_Layout.cshtml | 2 +- Website/Pages/SingleEntry.cshtml.cs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Website/Pages/Shared/_Layout.cshtml b/Website/Pages/Shared/_Layout.cshtml index 02d6472..eaa7c33 100644 --- a/Website/Pages/Shared/_Layout.cshtml +++ b/Website/Pages/Shared/_Layout.cshtml @@ -8,7 +8,7 @@ - + diff --git a/Website/Pages/SingleEntry.cshtml.cs b/Website/Pages/SingleEntry.cshtml.cs index c06b304..1de772f 100644 --- a/Website/Pages/SingleEntry.cshtml.cs +++ b/Website/Pages/SingleEntry.cshtml.cs @@ -30,9 +30,10 @@ public async Task OnGet(string nameEntry) return Redirect($"/entries?q={HttpUtility.UrlEncode(nameEntry)}"); } - ViewData["SocialTitle"] = name.Name; - ViewData["SocialPath"] = $"/entries/{HttpUtility.UrlEncode(name.Name)}"; - ViewData["SocialDescription"] = name.Meaning; + var encodedName = HttpUtility.UrlEncode(name.Name); + ViewData["SocialTitle"] = encodedName; + ViewData["SocialPath"] = $"/entries/{encodedName}"; + ViewData["SocialDescription"] = HttpUtility.UrlEncode(name.Meaning); Name = name; Letters = YorubaAlphabetService.YorubaAlphabet; From 06e57b15580ccf1cad99135bf846c7e46b2dfe27 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Mon, 2 Sep 2024 16:03:06 +0300 Subject: [PATCH 13/20] Replace Task.Delay with PeriodicTimer(). (#101) --- Infrastructure/Configuration/TwitterConfig.cs | 2 +- Infrastructure/Services/NamePostingService.cs | 27 ++++++++++--------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/Infrastructure/Configuration/TwitterConfig.cs b/Infrastructure/Configuration/TwitterConfig.cs index 3c814e7..5b46604 100644 --- a/Infrastructure/Configuration/TwitterConfig.cs +++ b/Infrastructure/Configuration/TwitterConfig.cs @@ -7,7 +7,7 @@ public record TwitterConfig( string AccessTokenSecret, string NameUrlPrefix, string TweetTemplate, - decimal TweetIntervalSeconds) + double TweetIntervalSeconds) { public TwitterConfig() : this("", "", "", "", "", "", default) { } } diff --git a/Infrastructure/Services/NamePostingService.cs b/Infrastructure/Services/NamePostingService.cs index 37d785c..234eacc 100644 --- a/Infrastructure/Services/NamePostingService.cs +++ b/Infrastructure/Services/NamePostingService.cs @@ -22,20 +22,20 @@ public class NamePostingService( private readonly ILogger _logger = logger; private readonly NameEntryService _nameEntryService = nameEntryService; private readonly TwitterConfig _twitterConfig = twitterConfig.Value; + private readonly PeriodicTimer _postingTimer = new (TimeSpan.FromSeconds(twitterConfig.Value.TweetIntervalSeconds)); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - var tweetIntervalMs = (int)(_twitterConfig.TweetIntervalSeconds * 1000); - while (!stoppingToken.IsCancellationRequested) + do { - if (!_nameQueue.TryDequeue(out var indexedName)) - { - await Task.Delay(tweetIntervalMs, stoppingToken); - continue; - } - + PostPublishedNameCommand? indexedName = null; try { + if (!_nameQueue.TryDequeue(out indexedName)) + { + continue; + } + string? tweetText = await BuildTweet(indexedName.Name); if (string.IsNullOrWhiteSpace(tweetText)) @@ -52,11 +52,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (Exception ex) { - _logger.LogError(ex, "Failed to tweet name: {name} to Twitter.", indexedName.Name); + _logger.LogError(ex, "Failed to tweet name: `{name}` to Twitter.", indexedName!.Name); } + } while (!stoppingToken.IsCancellationRequested && await _postingTimer.WaitForNextTickAsync(stoppingToken)); + } - await Task.Delay(tweetIntervalMs, stoppingToken); - } + public override async Task StopAsync(CancellationToken stoppingToken) + { + _postingTimer.Dispose(); + await base.StopAsync(stoppingToken); } private async Task BuildTweet(string name) @@ -68,6 +72,5 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) .Replace("{meaning}", nameEntry.Meaning.TrimEnd('.')) .Replace("{link}", link); } - } } From d2795ded15474cef729f0a2801fca8c3d0b8c06d Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Mon, 2 Sep 2024 16:22:51 +0300 Subject: [PATCH 14/20] Don't Encode Text Parts (#106) --- Website/Pages/SingleEntry.cshtml.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Website/Pages/SingleEntry.cshtml.cs b/Website/Pages/SingleEntry.cshtml.cs index 1de772f..c06b304 100644 --- a/Website/Pages/SingleEntry.cshtml.cs +++ b/Website/Pages/SingleEntry.cshtml.cs @@ -30,10 +30,9 @@ public async Task OnGet(string nameEntry) return Redirect($"/entries?q={HttpUtility.UrlEncode(nameEntry)}"); } - var encodedName = HttpUtility.UrlEncode(name.Name); - ViewData["SocialTitle"] = encodedName; - ViewData["SocialPath"] = $"/entries/{encodedName}"; - ViewData["SocialDescription"] = HttpUtility.UrlEncode(name.Meaning); + ViewData["SocialTitle"] = name.Name; + ViewData["SocialPath"] = $"/entries/{HttpUtility.UrlEncode(name.Name)}"; + ViewData["SocialDescription"] = name.Meaning; Name = name; Letters = YorubaAlphabetService.YorubaAlphabet; From 7f40406b3b34b8d4882c5bbfcc2b6a2bfe39ec40 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Fri, 13 Sep 2024 19:26:12 +0300 Subject: [PATCH 15/20] Use Hangfire to schedule tweets. (#108) Also, create unique index on the NameEntries_Name column. --- Api/Program.cs | 16 ++-- Api/appsettings.Development.json | 2 +- Application/Application.csproj | 13 ++- .../EventHandlers/NameIndexedEventHandler.cs | 2 +- .../PostPublishedNameCommandHandler.cs | 18 ++-- Application/Events/NameIndexedAdapter.cs | 2 +- .../Events/PostPublishedNameCommand.cs | 2 +- Application/Services/BasicAuthHandler.cs | 9 +- Application/Services/ITwitterService.cs | 7 ++ Application/Services/NameEntryService.cs | 2 +- Core/Core.csproj | 6 +- Core/Events/NameIndexed.cs | 10 +-- .../Infrastructure.MongoDB.csproj | 10 +-- .../Repositories/NameEntryRepository.cs | 14 ++++ .../Hangfire/DependencyInjection.cs | 51 +++++++++++ Infrastructure/Hangfire/HangfireAuthFilter.cs | 12 +++ Infrastructure/Infrastructure.csproj | 2 + Infrastructure/Services/NamePostingService.cs | 76 ----------------- .../{ => Twitter}/DependencyInjection.cs | 7 +- Infrastructure/Twitter/TwitterService.cs | 84 +++++++++++++++++++ 20 files changed, 212 insertions(+), 133 deletions(-) create mode 100644 Application/Services/ITwitterService.cs create mode 100644 Infrastructure/Hangfire/DependencyInjection.cs create mode 100644 Infrastructure/Hangfire/HangfireAuthFilter.cs delete mode 100644 Infrastructure/Services/NamePostingService.cs rename Infrastructure/{ => Twitter}/DependencyInjection.cs (90%) create mode 100644 Infrastructure/Twitter/TwitterService.cs diff --git a/Api/Program.cs b/Api/Program.cs index fcecba0..1128f4a 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -10,14 +10,14 @@ using Core.Events; using Core.StringObjectConverters; using FluentValidation; -using Infrastructure; +using Infrastructure.Twitter; using Infrastructure.MongoDB; -using Infrastructure.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.OpenApi.Models; using MySqlConnector; -using System.Collections.Concurrent; using System.Text.Json.Serialization; +using Hangfire; +using Infrastructure.Hangfire; var builder = WebApplication.CreateBuilder(args); var configuration = builder.Configuration; @@ -86,7 +86,7 @@ } }); }); -var mongoDbSettings = configuration.GetSection("MongoDB"); +var mongoDbSettings = configuration.GetRequiredSection("MongoDB"); services.InitializeDatabase(mongoDbSettings.GetValue("ConnectionString"), mongoDbSettings.GetValue("DatabaseName")); builder.Services.AddTransient(x => @@ -112,9 +112,11 @@ services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ExactNameSearchedAdapter).Assembly)); // Twitter integration configuration -services.AddSingleton>(); +services.AddSingleton(); services.AddTwitterClient(configuration); -services.AddHostedService(); + +builder.Services.AddMemoryCache(); +builder.Services.SetupHangfire(configuration.GetRequiredSection("MongoDB:ConnectionString").Value!); var app = builder.Build(); @@ -135,4 +137,6 @@ app.MapControllers(); +app.UseHangfireDashboard("/backJobMonitor"); + app.Run(); diff --git a/Api/appsettings.Development.json b/Api/appsettings.Development.json index 6ae5b70..63e58ce 100644 --- a/Api/appsettings.Development.json +++ b/Api/appsettings.Development.json @@ -14,6 +14,6 @@ }, "Twitter": { "TweetTemplate": "{name}: \"{meaning}\" {link}", - "TweetIntervalSeconds": 5 + "TweetIntervalSeconds": 60 } } diff --git a/Application/Application.csproj b/Application/Application.csproj index b8025e4..1310f6a 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -1,29 +1,26 @@  - - net6.0 + net8.0 enable enable - - - - - - + + + + \ No newline at end of file diff --git a/Application/EventHandlers/NameIndexedEventHandler.cs b/Application/EventHandlers/NameIndexedEventHandler.cs index 2f83e3a..dc09fc1 100644 --- a/Application/EventHandlers/NameIndexedEventHandler.cs +++ b/Application/EventHandlers/NameIndexedEventHandler.cs @@ -20,7 +20,7 @@ public NameIndexedEventHandler( public async Task Handle(NameIndexedAdapter notification, CancellationToken cancellationToken) { await _recentIndexesCache.Stack(notification.Name); - await _mediator.Publish(new PostPublishedNameCommand(notification.Name), cancellationToken); + await _mediator.Publish(new PostPublishedNameCommand(notification.Name, notification.Meaning), cancellationToken); } } } diff --git a/Application/EventHandlers/PostPublishedNameCommandHandler.cs b/Application/EventHandlers/PostPublishedNameCommandHandler.cs index 7f8b3e5..7e03e90 100644 --- a/Application/EventHandlers/PostPublishedNameCommandHandler.cs +++ b/Application/EventHandlers/PostPublishedNameCommandHandler.cs @@ -1,25 +1,23 @@ using Application.Events; +using Application.Services; using MediatR; -using System.Collections.Concurrent; namespace Application.EventHandlers { public class PostPublishedNameCommandHandler : INotificationHandler { - private readonly ConcurrentQueue _nameQueue; + private readonly ITwitterService _twitterService; - public PostPublishedNameCommandHandler(ConcurrentQueue nameQueue) + public PostPublishedNameCommandHandler( + ITwitterService twitterService) { - _nameQueue = nameQueue; + _twitterService = twitterService; + } - public Task Handle(PostPublishedNameCommand notification, CancellationToken cancellationToken) + public async Task Handle(PostPublishedNameCommand notification, CancellationToken cancellationToken) { - // Enqueue the indexed name for processing by the BackgroundService - _nameQueue.Enqueue(notification); - - // Return a completed task, so it doesn't block the main thread - return Task.CompletedTask; + await _twitterService.PostNewNameAsync(notification.Name, notification.Meaning, cancellationToken); } } } diff --git a/Application/Events/NameIndexedAdapter.cs b/Application/Events/NameIndexedAdapter.cs index b0d2be4..10c9f84 100644 --- a/Application/Events/NameIndexedAdapter.cs +++ b/Application/Events/NameIndexedAdapter.cs @@ -5,7 +5,7 @@ namespace Application.Events; public record NameIndexedAdapter : NameIndexed, INotification { - public NameIndexedAdapter(NameIndexed theEvent) : base(theEvent.Name) + public NameIndexedAdapter(NameIndexed theEvent) : base(theEvent.Name, theEvent.Meaning) { } } \ No newline at end of file diff --git a/Application/Events/PostPublishedNameCommand.cs b/Application/Events/PostPublishedNameCommand.cs index 62f21d7..4fb426f 100644 --- a/Application/Events/PostPublishedNameCommand.cs +++ b/Application/Events/PostPublishedNameCommand.cs @@ -2,7 +2,7 @@ namespace Application.Events { - public record PostPublishedNameCommand(string Name) : INotification + public record PostPublishedNameCommand(string Name, string Meaning) : INotification { } } diff --git a/Application/Services/BasicAuthHandler.cs b/Application/Services/BasicAuthHandler.cs index eb2bad1..f54c1fc 100644 --- a/Application/Services/BasicAuthHandler.cs +++ b/Application/Services/BasicAuthHandler.cs @@ -20,9 +20,8 @@ public BasicAuthenticationHandler( IUserRepository userRepository, IOptionsMonitor options, ILoggerFactory logger, - UrlEncoder encoder, - ISystemClock clock) - : base(options, logger, encoder, clock) + UrlEncoder encoder) + : base(options, logger, encoder) { _userRepository = userRepository; _logger = logger.CreateLogger(); @@ -30,12 +29,12 @@ public BasicAuthenticationHandler( protected override async Task HandleAuthenticateAsync() { - if (!Request.Headers.ContainsKey("Authorization")) + if (!Request.Headers.TryGetValue("Authorization", out Microsoft.Extensions.Primitives.StringValues value)) return await Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header")); try { - (string username, string password) = DecodeBasicAuthToken(Request.Headers["Authorization"]); + (string username, string password) = DecodeBasicAuthToken(value!); var matchingUser = await AuthenticateUser(username, password); if (matchingUser == null) diff --git a/Application/Services/ITwitterService.cs b/Application/Services/ITwitterService.cs new file mode 100644 index 0000000..00b3125 --- /dev/null +++ b/Application/Services/ITwitterService.cs @@ -0,0 +1,7 @@ +namespace Application.Services +{ + public interface ITwitterService + { + Task PostNewNameAsync(string name, string meaning, CancellationToken cancellationToken); + } +} diff --git a/Application/Services/NameEntryService.cs b/Application/Services/NameEntryService.cs index b1c8c12..a014b2f 100644 --- a/Application/Services/NameEntryService.cs +++ b/Application/Services/NameEntryService.cs @@ -114,7 +114,7 @@ public async Task PublishName(NameEntry nameEntry, string username) await _nameEntryRepository.Update(originalName, nameEntry); // TODO Later: Use the outbox pattern to enforce event publishing after the DB update (https://www.youtube.com/watch?v=032SfEBFIJs&t=913s). - await _eventPubService.PublishEvent(new NameIndexed(nameEntry.Name)); + await _eventPubService.PublishEvent(new NameIndexed(nameEntry.Name, nameEntry.Meaning)); } public async Task UpdateNameWithUnpublish(NameEntry nameEntry) diff --git a/Core/Core.csproj b/Core/Core.csproj index 132c02c..a6b9113 100644 --- a/Core/Core.csproj +++ b/Core/Core.csproj @@ -1,9 +1,7 @@ - - net6.0 + net8.0 enable enable - - + \ No newline at end of file diff --git a/Core/Events/NameIndexed.cs b/Core/Events/NameIndexed.cs index 705469a..52f6a65 100644 --- a/Core/Events/NameIndexed.cs +++ b/Core/Events/NameIndexed.cs @@ -1,12 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Core.Events +namespace Core.Events { - public record NameIndexed(string Name) + public record NameIndexed(string Name, string Meaning) { } } diff --git a/Infrastructure.MongoDB/Infrastructure.MongoDB.csproj b/Infrastructure.MongoDB/Infrastructure.MongoDB.csproj index 08c7018..fedebeb 100644 --- a/Infrastructure.MongoDB/Infrastructure.MongoDB.csproj +++ b/Infrastructure.MongoDB/Infrastructure.MongoDB.csproj @@ -1,18 +1,14 @@ - - net6.0 + net8.0 enable enable - - + - - - + \ No newline at end of file diff --git a/Infrastructure.MongoDB/Repositories/NameEntryRepository.cs b/Infrastructure.MongoDB/Repositories/NameEntryRepository.cs index 515dfff..73b5ee1 100644 --- a/Infrastructure.MongoDB/Repositories/NameEntryRepository.cs +++ b/Infrastructure.MongoDB/Repositories/NameEntryRepository.cs @@ -21,6 +21,20 @@ public NameEntryRepository( { _nameEntryCollection = database.GetCollection("NameEntries"); _eventPubService = eventPubService; + + CreateIndexes(); + } + + private void CreateIndexes() + { + var indexKeys = Builders.IndexKeys.Ascending(x => x.Name); + var indexOptions = new CreateIndexOptions + { + Unique = true, + Name = "IX_NameEntries_Name_Unique", + Background = true + }; + _nameEntryCollection.Indexes.CreateOne(new CreateIndexModel(indexKeys, indexOptions)); } public async Task FindById(string id) diff --git a/Infrastructure/Hangfire/DependencyInjection.cs b/Infrastructure/Hangfire/DependencyInjection.cs new file mode 100644 index 0000000..d9e7642 --- /dev/null +++ b/Infrastructure/Hangfire/DependencyInjection.cs @@ -0,0 +1,51 @@ +using Hangfire; +using Hangfire.Mongo; +using MongoDB.Driver; +using Hangfire.Mongo.Migration.Strategies; +using Hangfire.Mongo.Migration.Strategies.Backup; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Api.Utilities; + +namespace Infrastructure.Hangfire +{ + public static class DependencyInjection + { + public static IServiceCollection SetupHangfire(this IServiceCollection services, string mongoConnectionString) + { + var mongoUrlBuilder = new MongoUrlBuilder(mongoConnectionString); + var mongoClient = new MongoClient(mongoUrlBuilder.ToMongoUrl()); + + services.AddHangfire(configuration => configuration + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UseMongoStorage(mongoClient, mongoUrlBuilder.DatabaseName, new MongoStorageOptions + { + MigrationOptions = new MongoMigrationOptions + { + MigrationStrategy = new MigrateMongoMigrationStrategy(), + BackupStrategy = new CollectionMongoBackupStrategy() + }, + Prefix = "hangfire.mongo", + CheckConnection = true, + CheckQueuedJobsStrategy = CheckQueuedJobsStrategy.TailNotificationsCollection + }) + ); + + services.AddHangfireServer(serverOptions => + { + serverOptions.ServerName = "Hangfire.Mongo server 1"; + }); + return services; + } + + public static void UseHangfireDashboard(this IApplicationBuilder app, string dashboardPath) + { + app.UseHangfireDashboard(dashboardPath, new DashboardOptions + { + Authorization = [new HangfireAuthFilter()] + }); + } + } +} diff --git a/Infrastructure/Hangfire/HangfireAuthFilter.cs b/Infrastructure/Hangfire/HangfireAuthFilter.cs new file mode 100644 index 0000000..ad7fd43 --- /dev/null +++ b/Infrastructure/Hangfire/HangfireAuthFilter.cs @@ -0,0 +1,12 @@ +using Hangfire.Dashboard; + +namespace Api.Utilities +{ + public class HangfireAuthFilter : IDashboardAuthorizationFilter + { + public bool Authorize(DashboardContext context) + { + return context.GetHttpContext().Request.Host.Host == "localhost"; + } + } +} diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj index b2ef52c..7b37e79 100644 --- a/Infrastructure/Infrastructure.csproj +++ b/Infrastructure/Infrastructure.csproj @@ -7,6 +7,8 @@ + + diff --git a/Infrastructure/Services/NamePostingService.cs b/Infrastructure/Services/NamePostingService.cs deleted file mode 100644 index 772e574..0000000 --- a/Infrastructure/Services/NamePostingService.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Application.Domain; -using Application.Events; -using Infrastructure.Configuration; -using Infrastructure.Twitter; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System.Collections.Concurrent; - -namespace Infrastructure.Services -{ - public class NamePostingService( - ConcurrentQueue nameQueue, - ITwitterClientV2 twitterApiClient, - ILogger logger, - NameEntryService nameEntryService, - IOptions twitterConfig) : BackgroundService - { - private const string TweetComposeFailure = "Failed to build tweet for name: {name}. It was not found in the database."; - private readonly ConcurrentQueue _nameQueue = nameQueue; - private readonly ITwitterClientV2 _twitterApiClient = twitterApiClient; - private readonly ILogger _logger = logger; - private readonly NameEntryService _nameEntryService = nameEntryService; - private readonly TwitterConfig _twitterConfig = twitterConfig.Value; - private readonly PeriodicTimer _postingTimer = new (TimeSpan.FromSeconds(twitterConfig.Value.TweetIntervalSeconds)); - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - do - { - PostPublishedNameCommand? indexedName = null; - try - { - if (!_nameQueue.TryDequeue(out indexedName)) - { - continue; - } - - string? tweetText = await BuildTweet(indexedName.Name); - - if (string.IsNullOrWhiteSpace(tweetText)) - { - _logger.LogWarning(TweetComposeFailure, indexedName.Name); - continue; - } - - var tweet = await _twitterApiClient.PostTweet(tweetText); - if (tweet != null) - { - _logger.LogInformation("Tweeted name: {name} successfully with ID: {tweetId}", indexedName.Name, tweet.Id); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to tweet name: `{name}` to Twitter.", indexedName!.Name); - } - } while (!stoppingToken.IsCancellationRequested && await _postingTimer.WaitForNextTickAsync(stoppingToken)); - } - - public override async Task StopAsync(CancellationToken stoppingToken) - { - _postingTimer.Dispose(); - await base.StopAsync(stoppingToken); - } - - private async Task BuildTweet(string name) - { - string link = $"{_twitterConfig.NameUrlPrefix}/{name}"; - var nameEntry = await _nameEntryService.LoadName(name); - return nameEntry == null ? null : _twitterConfig.TweetTemplate - .Replace("{name}", nameEntry.Name) - .Replace("{meaning}", nameEntry.Meaning.TrimEnd('.')) - .Replace("{link}", link); - } - } -} diff --git a/Infrastructure/DependencyInjection.cs b/Infrastructure/Twitter/DependencyInjection.cs similarity index 90% rename from Infrastructure/DependencyInjection.cs rename to Infrastructure/Twitter/DependencyInjection.cs index 9b95dbb..8c7ba3e 100644 --- a/Infrastructure/DependencyInjection.cs +++ b/Infrastructure/Twitter/DependencyInjection.cs @@ -1,12 +1,11 @@ using Infrastructure.Configuration; -using Infrastructure.Twitter; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Tweetinvi; -namespace Infrastructure +namespace Infrastructure.Twitter { - public static class DependencyInjection + public static partial class DependencyInjection { private const string ConfigSectionName = "Twitter"; @@ -26,7 +25,7 @@ public static IServiceCollection AddTwitterClient(this IServiceCollection servic twitterConfig.AccessTokenSecret ); }); - + services.AddSingleton(); return services; } diff --git a/Infrastructure/Twitter/TwitterService.cs b/Infrastructure/Twitter/TwitterService.cs new file mode 100644 index 0000000..0b6eb53 --- /dev/null +++ b/Infrastructure/Twitter/TwitterService.cs @@ -0,0 +1,84 @@ +using Application.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Diagnostics; +using Microsoft.Extensions.Caching.Memory; +using Hangfire; +using Infrastructure.Configuration; + +namespace Infrastructure.Twitter +{ + public class TwitterService( + ITwitterClientV2 twitterApiClient, + ILogger logger, + IOptions twitterConfig, + IMemoryCache cache, + IBackgroundJobClientV2 backgroundJobClient) : ITwitterService + { + private readonly ITwitterClientV2 _twitterApiClient = twitterApiClient; + private readonly ILogger _logger = logger; + private readonly TwitterConfig _twitterConfig = twitterConfig.Value; + private readonly IMemoryCache _memoryCache = cache; + private readonly IBackgroundJobClientV2 _backgroundJobClient = backgroundJobClient; + private static readonly SemaphoreSlim _semaphore; + private const string LastTweetPublishedKey = "LastTweetPublished"; + + static TwitterService() + { + _semaphore = new(1, 1); + } + + private string BuildNameTweet(string name, string meaning) + { + string link = $"{_twitterConfig.NameUrlPrefix}/{name}"; + return _twitterConfig.TweetTemplate + .Replace("{name}", name) + .Replace("{meaning}", meaning.TrimEnd('.')) + .Replace("{link}", link); + } + + public async Task PostNewNameAsync(string name, string meaning, CancellationToken cancellationToken) + { + var theTweet = BuildNameTweet(name, meaning); + await PostTweetAsync(theTweet, cancellationToken); + } + + private async Task PostTweetAsync(string tweetText, CancellationToken cancellationToken) + { + await _semaphore.WaitAsync(cancellationToken); // We want to be scheduling only one tweet at a time. + try + { + var foundLastPublished = _memoryCache.TryGetValue(LastTweetPublishedKey, out DateTimeOffset lastTweetPublished); + var nextTweetTime = lastTweetPublished.AddSeconds(_twitterConfig.TweetIntervalSeconds); + + if (foundLastPublished && nextTweetTime > DateTimeOffset.Now) + { + _backgroundJobClient.Schedule(() => SendTweetAsync(tweetText), nextTweetTime); + } + else + { + nextTweetTime = DateTimeOffset.Now; + _backgroundJobClient.Enqueue(() => SendTweetAsync(tweetText)); + } + + _memoryCache.Set(LastTweetPublishedKey, nextTweetTime); + } + finally + { + _semaphore.Release(); + } + } + + public async Task SendTweetAsync(string tweetText) + { + if (!Debugger.IsAttached) // To prevent tweets from getting posted while testing. Could be better, but... + { + var tweet = await _twitterApiClient.PostTweet(tweetText); + if (tweet != null) + { + _logger.LogInformation("Tweet was posted successfully with ID: {tweetId}", tweet.Id); + } + } + } + } +} From 6c6d1b14a54e22c23125d5ac9f2a5439c16b1c29 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Mon, 16 Sep 2024 22:39:14 +0300 Subject: [PATCH 16/20] Feature/use distributed caching (#110) * Implement Redis caching --- Api/Api.csproj | 1 + Api/Controllers/SearchController.cs | 6 +- Api/Program.cs | 31 ++++++--- Api/appsettings.Development.json | 3 + Api/appsettings.json | 6 ++ Application/Cache/ICacheService.cs | 39 +++++++++++ Application/Cache/InMemoryCache.cs | 63 ----------------- Application/Cache/RecentIndexesCache.cs | 8 --- Application/Cache/RecentSearchesCache.cs | 28 -------- Infrastructure/Configuration/RedisConfig.cs | 7 ++ .../Hangfire/DependencyInjection.cs | 3 +- Infrastructure/Infrastructure.csproj | 2 + Infrastructure/Redis/DependencyInjection.cs | 21 ++++++ Infrastructure/Redis/RedisCache.cs | 13 ++++ .../Redis/RedisRecentIndexesCache.cs | 46 +++++++++++++ .../Redis/RedisRecentSearchesCache.cs | 67 +++++++++++++++++++ docker-compose.yml | 11 +++ 17 files changed, 244 insertions(+), 111 deletions(-) create mode 100644 Application/Cache/ICacheService.cs delete mode 100644 Application/Cache/InMemoryCache.cs delete mode 100644 Application/Cache/RecentIndexesCache.cs delete mode 100644 Application/Cache/RecentSearchesCache.cs create mode 100644 Infrastructure/Configuration/RedisConfig.cs create mode 100644 Infrastructure/Redis/DependencyInjection.cs create mode 100644 Infrastructure/Redis/RedisCache.cs create mode 100644 Infrastructure/Redis/RedisRecentIndexesCache.cs create mode 100644 Infrastructure/Redis/RedisRecentSearchesCache.cs diff --git a/Api/Api.csproj b/Api/Api.csproj index 672f7ae..7a53800 100644 --- a/Api/Api.csproj +++ b/Api/Api.csproj @@ -8,6 +8,7 @@ ..\docker-compose.dcproj + diff --git a/Api/Controllers/SearchController.cs b/Api/Controllers/SearchController.cs index 9d2250b..90714fa 100644 --- a/Api/Controllers/SearchController.cs +++ b/Api/Controllers/SearchController.cs @@ -55,9 +55,9 @@ public async Task Search([FromQuery(Name = "q"), Required] string var matches = await _searchService.Search(searchTerm); // TODO: Check if the comparison here removes takes diacrits into consideration - if (matches.Count() == 1 && matches.First().Name.ToLower() == searchTerm.ToLower()) + if (matches.Count() == 1 && matches.First().Name.Equals(searchTerm, StringComparison.CurrentCultureIgnoreCase)) { - await _eventPubService.PublishEvent(new ExactNameSearched(searchTerm)); + await _eventPubService.PublishEvent(new ExactNameSearched(matches.First().Name)); } return Ok(matches.MapToDtoCollection()); @@ -96,7 +96,7 @@ public async Task SearchOne(string searchTerm) if(nameEntry != null) { - await _eventPubService.PublishEvent(new ExactNameSearched(searchTerm)); + await _eventPubService.PublishEvent(new ExactNameSearched(nameEntry.Name)); } return Ok(nameEntry); diff --git a/Api/Program.cs b/Api/Program.cs index 1128f4a..a3d1baf 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -18,6 +18,8 @@ using System.Text.Json.Serialization; using Hangfire; using Infrastructure.Hangfire; +using Infrastructure.Redis; +using Ardalis.GuardClauses; var builder = WebApplication.CreateBuilder(args); var configuration = builder.Configuration; @@ -44,8 +46,15 @@ services.AddAuthorization(options => { options.AddPolicy("AdminOnly", policy => policy.RequireRole(Role.ADMIN.ToString())); - options.AddPolicy("AdminAndLexicographers", policy => policy.RequireRole(Role.ADMIN.ToString(), Role.PRO_LEXICOGRAPHER.ToString(), Role.BASIC_LEXICOGRAPHER.ToString())); - options.AddPolicy("AdminAndProLexicographers", policy => policy.RequireRole(Role.ADMIN.ToString(), Role.PRO_LEXICOGRAPHER.ToString())); + options.AddPolicy("AdminAndLexicographers", policy => policy.RequireRole( + Role.ADMIN.ToString(), + Role.PRO_LEXICOGRAPHER.ToString(), + Role.BASIC_LEXICOGRAPHER.ToString() + )); + options.AddPolicy("AdminAndProLexicographers", policy => policy.RequireRole( + Role.ADMIN.ToString(), + Role.PRO_LEXICOGRAPHER.ToString() + )); }); services.AddControllers().AddJsonOptions(options => @@ -87,10 +96,12 @@ }); }); var mongoDbSettings = configuration.GetRequiredSection("MongoDB"); -services.InitializeDatabase(mongoDbSettings.GetValue("ConnectionString"), mongoDbSettings.GetValue("DatabaseName")); +services.InitializeDatabase( + Guard.Against.NullOrEmpty(mongoDbSettings.GetValue("ConnectionString")), + Guard.Against.NullOrEmpty(mongoDbSettings.GetValue("DatabaseName"))); builder.Services.AddTransient(x => - new MySqlConnection(builder.Configuration.GetSection("MySQL:ConnectionString").Value)); + new MySqlConnection(Guard.Against.NullOrEmpty(configuration.GetSection("MySQL:ConnectionString").Value))); services.AddSingleton(); services.AddSingleton(); @@ -103,8 +114,8 @@ services.AddScoped(); services.AddScoped(); services.AddScoped(); -services.AddSingleton(); -services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); //Validation services.AddValidatorsFromAssemblyContaining(); @@ -116,7 +127,8 @@ services.AddTwitterClient(configuration); builder.Services.AddMemoryCache(); -builder.Services.SetupHangfire(configuration.GetRequiredSection("MongoDB:ConnectionString").Value!); +builder.Services.SetupHangfire(Guard.Against.NullOrEmpty(configuration.GetRequiredSection("MongoDB:ConnectionString").Value)); +builder.Services.SetupRedis(configuration); var app = builder.Build(); @@ -137,6 +149,9 @@ app.MapControllers(); -app.UseHangfireDashboard("/backJobMonitor"); +if (app.Environment.IsDevelopment()) +{ + app.UseHangfireDashboard("/backJobMonitor"); +} app.Run(); diff --git a/Api/appsettings.Development.json b/Api/appsettings.Development.json index 63e58ce..4dea487 100644 --- a/Api/appsettings.Development.json +++ b/Api/appsettings.Development.json @@ -15,5 +15,8 @@ "Twitter": { "TweetTemplate": "{name}: \"{meaning}\" {link}", "TweetIntervalSeconds": 60 + }, + "Redis": { + "DatabaseIndex": 1 } } diff --git a/Api/appsettings.json b/Api/appsettings.json index 07c914b..7b3f023 100644 --- a/Api/appsettings.json +++ b/Api/appsettings.json @@ -7,6 +7,9 @@ } }, "AllowedHosts": "*", + "ConnectionStrings": { + "Redis": "redis:6379" + }, "Twitter": { "AccessToken": "your-access-token", "AccessTokenSecret": "your-access-token-secret", @@ -15,5 +18,8 @@ "NameUrlPrefix": "https://www.yorubaname.com/entries", "TweetTemplate": "New name entry: {name}, {meaning}. More here: {link}", "TweetIntervalSeconds": 180 + }, + "Redis": { + "DatabaseIndex": 0 } } diff --git a/Application/Cache/ICacheService.cs b/Application/Cache/ICacheService.cs new file mode 100644 index 0000000..8cc97c4 --- /dev/null +++ b/Application/Cache/ICacheService.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Application.Cache +{ + public interface ICacheService + { + /// + /// Retrieves a collection of items from the cache. + /// + /// The key under which the items are cached. + /// A task that represents the asynchronous operation. The task result contains a collection of items. + Task> GetAsync(string key); + + /// + /// Adds an item to the cache and updates its recency. + /// + /// The key under which the item is cached. + /// The item to be added to the cache. + /// A task that represents the asynchronous operation. + Task StackAsync(string key, T item); + + /// + /// Removes an item from the cache. + /// + /// The key of the item to be removed. + /// A task that represents the asynchronous operation. The task result contains a boolean indicating whether the removal was successful. + Task RemoveAsync(string key, string searchTerm); + + /// + /// Retrieves the most popular items based on their frequency. + /// + /// A task that represents the asynchronous operation. The task result contains a collection of the most popular items. + Task> GetMostPopularAsync(string key); + } +} diff --git a/Application/Cache/InMemoryCache.cs b/Application/Cache/InMemoryCache.cs deleted file mode 100644 index 456bb32..0000000 --- a/Application/Cache/InMemoryCache.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Core.Cache; - -namespace Application.Cache -{ - public abstract class InMemoryCache : ICache - { - protected readonly List _itemCache; - protected readonly Dictionary _itemFrequency; - - private int recencyLimit = 5; - - public InMemoryCache() - { - _itemCache = new List(); - _itemFrequency = new Dictionary(); - } - - public async Task> Get() - { - return await Task.FromResult(_itemCache.ToArray()); - } - - public async Task Stack(string name) - { - await Insert(name); - int count = _itemCache.Count; - if (count > recencyLimit) - { - _itemCache.RemoveAt(count - 1); - } - } - - private async Task Insert(string name) - { - if (_itemCache.Contains(name)) - { - _itemCache.Remove(name); - } - _itemCache.Insert(0, name); - await UpdateFrequency(name); - } - - public async Task Remove(string name) - { - if (_itemCache.Contains(name)) - { - _itemCache.Remove(name); - _itemFrequency.Remove(name); - return true; - } - return false; - } - - private async Task UpdateFrequency(string name) - { - if (!_itemFrequency.ContainsKey(name)) - { - _itemFrequency.Add(name, 0); - } - _itemFrequency[name]++; - } - } -} diff --git a/Application/Cache/RecentIndexesCache.cs b/Application/Cache/RecentIndexesCache.cs deleted file mode 100644 index 1f999b6..0000000 --- a/Application/Cache/RecentIndexesCache.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Core.Cache; - -namespace Application.Cache -{ - public class RecentIndexesCache : InMemoryCache, IRecentIndexesCache - { - } -} diff --git a/Application/Cache/RecentSearchesCache.cs b/Application/Cache/RecentSearchesCache.cs deleted file mode 100644 index 5fb4abd..0000000 --- a/Application/Cache/RecentSearchesCache.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Core.Cache; - -namespace Application.Cache -{ - public class RecentSearchesCache : InMemoryCache, IRecentSearchesCache - { - private int popularListLimit = 5; - - public RecentSearchesCache() : base() - { - } - - - public async Task> GetMostPopular() - { - var frequency = GetNameWithSearchFrequency(); - return frequency.Select(item => item.Key).Take(popularListLimit); - } - - - private Dictionary GetNameWithSearchFrequency() - { - return _itemFrequency - .OrderByDescending(item => item.Value) - .ToDictionary(item => item.Key, item => item.Value); - } - } -} diff --git a/Infrastructure/Configuration/RedisConfig.cs b/Infrastructure/Configuration/RedisConfig.cs new file mode 100644 index 0000000..973cd05 --- /dev/null +++ b/Infrastructure/Configuration/RedisConfig.cs @@ -0,0 +1,7 @@ +namespace Infrastructure.Configuration +{ + public record RedisConfig + { + public int DatabaseIndex { get; set; } + } +} diff --git a/Infrastructure/Hangfire/DependencyInjection.cs b/Infrastructure/Hangfire/DependencyInjection.cs index d9e7642..499775c 100644 --- a/Infrastructure/Hangfire/DependencyInjection.cs +++ b/Infrastructure/Hangfire/DependencyInjection.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Builder; using Api.Utilities; +using Ardalis.GuardClauses; namespace Infrastructure.Hangfire { @@ -20,7 +21,7 @@ public static IServiceCollection SetupHangfire(this IServiceCollection services, .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() - .UseMongoStorage(mongoClient, mongoUrlBuilder.DatabaseName, new MongoStorageOptions + .UseMongoStorage(mongoClient, Guard.Against.NullOrEmpty(mongoUrlBuilder.DatabaseName), new MongoStorageOptions { MigrationOptions = new MongoMigrationOptions { diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj index 7b37e79..6b0bce6 100644 --- a/Infrastructure/Infrastructure.csproj +++ b/Infrastructure/Infrastructure.csproj @@ -7,11 +7,13 @@ + + diff --git a/Infrastructure/Redis/DependencyInjection.cs b/Infrastructure/Redis/DependencyInjection.cs new file mode 100644 index 0000000..9dc6fd9 --- /dev/null +++ b/Infrastructure/Redis/DependencyInjection.cs @@ -0,0 +1,21 @@ +using Ardalis.GuardClauses; +using Infrastructure.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +namespace Infrastructure.Redis +{ + public static class DependencyInjection + { + private const string SectionName = "Redis"; + + public static IServiceCollection SetupRedis(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetRequiredSection(SectionName)); + var redisConnectionString = Guard.Against.NullOrEmpty(configuration.GetConnectionString(SectionName)); + services.AddSingleton(ConnectionMultiplexer.Connect(redisConnectionString)); + return services; + } + } +} diff --git a/Infrastructure/Redis/RedisCache.cs b/Infrastructure/Redis/RedisCache.cs new file mode 100644 index 0000000..71dd1a0 --- /dev/null +++ b/Infrastructure/Redis/RedisCache.cs @@ -0,0 +1,13 @@ +using Infrastructure.Configuration; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Infrastructure.Redis +{ + public abstract class RedisCache( + IConnectionMultiplexer connectionMultiplexer, + IOptions redisConfig) + { + protected readonly IDatabase _cache = connectionMultiplexer.GetDatabase(redisConfig.Value.DatabaseIndex); + } +} diff --git a/Infrastructure/Redis/RedisRecentIndexesCache.cs b/Infrastructure/Redis/RedisRecentIndexesCache.cs new file mode 100644 index 0000000..755fee1 --- /dev/null +++ b/Infrastructure/Redis/RedisRecentIndexesCache.cs @@ -0,0 +1,46 @@ +using Core.Cache; +using Infrastructure.Configuration; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Infrastructure.Redis +{ + public class RedisRecentIndexesCache( + IConnectionMultiplexer connectionMultiplexer, + IOptions redisConfig) : RedisCache(connectionMultiplexer, redisConfig), IRecentIndexesCache + { + private const string Key = "recent_indexes"; + private const int MaxItemsToReturn = 5; + private const int MaxItemsToStore = 10; + + public async Task> Get() + { + var results = await _cache.SortedSetRangeByRankAsync(Key, 0, MaxItemsToReturn - 1, Order.Descending); + return results.Select(r => r.ToString()); + } + + public async Task Remove(string item) + { + var tran = _cache.CreateTransaction(); + _ = tran.SortedSetRemoveAsync(Key, item); + return await tran.ExecuteAsync(); + } + + public async Task Stack(string item) + { + // Use a Redis transaction to ensure atomicity of both operations + var transaction = _cache.CreateTransaction(); + + // Add the search term to the front of the Redis list + _ = transaction.SortedSetAddAsync(Key, item, DateTime.UtcNow.Ticks); + _ = transaction.SortedSetRemoveRangeByRankAsync(Key, 0, -(MaxItemsToStore + 1)); + + // Execute the transaction + bool committed = await transaction.ExecuteAsync(); + if (!committed) + { + throw new Exception("Redis Transaction failed"); + } + } + } +} diff --git a/Infrastructure/Redis/RedisRecentSearchesCache.cs b/Infrastructure/Redis/RedisRecentSearchesCache.cs new file mode 100644 index 0000000..e08f764 --- /dev/null +++ b/Infrastructure/Redis/RedisRecentSearchesCache.cs @@ -0,0 +1,67 @@ +using Core.Cache; +using Infrastructure.Configuration; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Infrastructure.Redis +{ + public class RedisRecentSearchesCache( + IConnectionMultiplexer connectionMultiplexer, + IOptions redisConfig) : RedisCache(connectionMultiplexer, redisConfig), IRecentSearchesCache + { + private const string RecentSearchesKey = "recent_searches"; + private const string PopularSearchesKey = "popular_searches"; + private const int MaxItemsToReturn = 5; + private const int MaxRecentSearches = 10; + private const int MaxPopularSearches = 1000; // Use a large number to ensure that items have time to get promoted. + + public async Task> Get() + { + var results = await _cache.SortedSetRangeByRankAsync(RecentSearchesKey, 0, MaxItemsToReturn -1, Order.Descending); + return results.Select(r => r.ToString()); + } + + public async Task> GetMostPopular() + { + var results = await _cache.SortedSetRangeByRankAsync(PopularSearchesKey, 0, MaxItemsToReturn -1, Order.Descending); + return results.Select(r => r.ToString()); + } + + public async Task Remove(string item) + { + var tran = _cache.CreateTransaction(); + _ = tran.SortedSetRemoveAsync(RecentSearchesKey, item); + _ = tran.SortedSetRemoveAsync(PopularSearchesKey, item); + return await tran.ExecuteAsync(); + } + + public async Task Stack(string item) + { + // Use a Redis transaction to ensure atomicity of both operations + var transaction = _cache.CreateTransaction(); + + // Add the search term to the front of the Redis list + _ = transaction.SortedSetAddAsync(RecentSearchesKey, item, DateTime.UtcNow.Ticks); + _ = transaction.SortedSetRemoveRangeByRankAsync(RecentSearchesKey, 0, -(MaxRecentSearches + 1)); + + // TODO: Do a periodic caching, like daily where the most popular items from the previous period are brought forward into the next day + var currentScore = (await _cache.SortedSetScoreAsync(PopularSearchesKey, item)) ?? 0; + _ = transaction.SortedSetAddAsync(PopularSearchesKey, item, (int)currentScore + 1 + GetNormalizedTimestamp()); + _ = transaction.SortedSetRemoveRangeByRankAsync(PopularSearchesKey, 0, -(MaxPopularSearches + 1)); + + // Execute the transaction + bool committed = await transaction.ExecuteAsync(); + if (!committed) + { + throw new Exception("Redis Transaction failed"); + } + } + + static double GetNormalizedTimestamp() + { + // This can be improved by addressing the time-cycle reset problem. + long unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + return (unixTimestamp % 1000000) / 1000000.0; + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 1890559..4e69291 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,10 +14,21 @@ services: dockerfile: Api/Dockerfile depends_on: - mongodb + - redis mongodb: image: mongo:latest container_name: ynd-mongodb ports: - "27020:27017" + redis: + image: redis:latest + container_name: redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + +volumes: + redis-data: From 6867a3eec6493f140e5880ceb090d25cc8051c6b Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Mon, 16 Sep 2024 23:29:23 +0300 Subject: [PATCH 17/20] Reduce tweet attempts to just 3 (#111) --- Infrastructure/Twitter/TwitterService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Infrastructure/Twitter/TwitterService.cs b/Infrastructure/Twitter/TwitterService.cs index 0b6eb53..5dc8057 100644 --- a/Infrastructure/Twitter/TwitterService.cs +++ b/Infrastructure/Twitter/TwitterService.cs @@ -69,6 +69,7 @@ private async Task PostTweetAsync(string tweetText, CancellationToken cancellati } } + [AutomaticRetry(Attempts = 3)] public async Task SendTweetAsync(string tweetText) { if (!Debugger.IsAttached) // To prevent tweets from getting posted while testing. Could be better, but... From b22a917d3e715eddc1010bdee39f89198e4bc8d5 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Tue, 17 Sep 2024 15:49:11 +0300 Subject: [PATCH 18/20] Bugfix/remove limit on popular words (#113) * Replace in-memory caching with Redis caching. * Solve cycling problem for the next 100+ years --- Api/Program.cs | 2 - Application/Cache/ICacheService.cs | 39 ------------------- Core/Cache/IRecentIndexesCache.cs | 2 +- Core/Cache/IRecentSearchesCache.cs | 2 +- Core/Cache/{ICache.cs => ISetBasedCache.cs} | 2 +- Core/Cache/ISimpleCache.cs | 8 ++++ Infrastructure/Redis/DependencyInjection.cs | 2 + .../Redis/RedisRecentSearchesCache.cs | 15 ++++--- Infrastructure/Redis/SimpleRedisCache.cs | 32 +++++++++++++++ Infrastructure/Twitter/TwitterService.cs | 12 +++--- 10 files changed, 60 insertions(+), 56 deletions(-) delete mode 100644 Application/Cache/ICacheService.cs rename Core/Cache/{ICache.cs => ISetBasedCache.cs} (86%) create mode 100644 Core/Cache/ISimpleCache.cs create mode 100644 Infrastructure/Redis/SimpleRedisCache.cs diff --git a/Api/Program.cs b/Api/Program.cs index a3d1baf..d8f4f4b 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -1,5 +1,4 @@ using Api.ExceptionHandler; -using Application.Cache; using Application.Domain; using Application.Events; using Application.Migrator; @@ -126,7 +125,6 @@ services.AddSingleton(); services.AddTwitterClient(configuration); -builder.Services.AddMemoryCache(); builder.Services.SetupHangfire(Guard.Against.NullOrEmpty(configuration.GetRequiredSection("MongoDB:ConnectionString").Value)); builder.Services.SetupRedis(configuration); diff --git a/Application/Cache/ICacheService.cs b/Application/Cache/ICacheService.cs deleted file mode 100644 index 8cc97c4..0000000 --- a/Application/Cache/ICacheService.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Application.Cache -{ - public interface ICacheService - { - /// - /// Retrieves a collection of items from the cache. - /// - /// The key under which the items are cached. - /// A task that represents the asynchronous operation. The task result contains a collection of items. - Task> GetAsync(string key); - - /// - /// Adds an item to the cache and updates its recency. - /// - /// The key under which the item is cached. - /// The item to be added to the cache. - /// A task that represents the asynchronous operation. - Task StackAsync(string key, T item); - - /// - /// Removes an item from the cache. - /// - /// The key of the item to be removed. - /// A task that represents the asynchronous operation. The task result contains a boolean indicating whether the removal was successful. - Task RemoveAsync(string key, string searchTerm); - - /// - /// Retrieves the most popular items based on their frequency. - /// - /// A task that represents the asynchronous operation. The task result contains a collection of the most popular items. - Task> GetMostPopularAsync(string key); - } -} diff --git a/Core/Cache/IRecentIndexesCache.cs b/Core/Cache/IRecentIndexesCache.cs index 18535f4..f533cd7 100644 --- a/Core/Cache/IRecentIndexesCache.cs +++ b/Core/Cache/IRecentIndexesCache.cs @@ -1,6 +1,6 @@ namespace Core.Cache { - public interface IRecentIndexesCache : ICache + public interface IRecentIndexesCache : ISetBasedCache { } } \ No newline at end of file diff --git a/Core/Cache/IRecentSearchesCache.cs b/Core/Cache/IRecentSearchesCache.cs index b601774..73d9bd3 100644 --- a/Core/Cache/IRecentSearchesCache.cs +++ b/Core/Cache/IRecentSearchesCache.cs @@ -1,6 +1,6 @@ namespace Core.Cache { - public interface IRecentSearchesCache : ICache + public interface IRecentSearchesCache : ISetBasedCache { Task> GetMostPopular(); } diff --git a/Core/Cache/ICache.cs b/Core/Cache/ISetBasedCache.cs similarity index 86% rename from Core/Cache/ICache.cs rename to Core/Cache/ISetBasedCache.cs index d86b3e1..581739e 100644 --- a/Core/Cache/ICache.cs +++ b/Core/Cache/ISetBasedCache.cs @@ -6,7 +6,7 @@ namespace Core.Cache { - public interface ICache + public interface ISetBasedCache { Task> Get(); Task Stack(T item); diff --git a/Core/Cache/ISimpleCache.cs b/Core/Cache/ISimpleCache.cs new file mode 100644 index 0000000..d943fb8 --- /dev/null +++ b/Core/Cache/ISimpleCache.cs @@ -0,0 +1,8 @@ +namespace Core.Cache +{ + public interface ISimpleCache + { + Task SetAsync(string key, T value, TimeSpan? expiry = null); + Task GetAsync(string key); + } +} diff --git a/Infrastructure/Redis/DependencyInjection.cs b/Infrastructure/Redis/DependencyInjection.cs index 9dc6fd9..2e7c906 100644 --- a/Infrastructure/Redis/DependencyInjection.cs +++ b/Infrastructure/Redis/DependencyInjection.cs @@ -1,4 +1,5 @@ using Ardalis.GuardClauses; +using Core.Cache; using Infrastructure.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -15,6 +16,7 @@ public static IServiceCollection SetupRedis(this IServiceCollection services, IC services.Configure(configuration.GetRequiredSection(SectionName)); var redisConnectionString = Guard.Against.NullOrEmpty(configuration.GetConnectionString(SectionName)); services.AddSingleton(ConnectionMultiplexer.Connect(redisConnectionString)); + services.AddSingleton(); return services; } } diff --git a/Infrastructure/Redis/RedisRecentSearchesCache.cs b/Infrastructure/Redis/RedisRecentSearchesCache.cs index e08f764..e34ad2b 100644 --- a/Infrastructure/Redis/RedisRecentSearchesCache.cs +++ b/Infrastructure/Redis/RedisRecentSearchesCache.cs @@ -11,9 +11,14 @@ public class RedisRecentSearchesCache( { private const string RecentSearchesKey = "recent_searches"; private const string PopularSearchesKey = "popular_searches"; + private static readonly DateTime StartDate; private const int MaxItemsToReturn = 5; private const int MaxRecentSearches = 10; - private const int MaxPopularSearches = 1000; // Use a large number to ensure that items have time to get promoted. + + static RedisRecentSearchesCache() + { + StartDate = new(2024, 9, 17); // Do not change + } public async Task> Get() { @@ -46,8 +51,7 @@ public async Task Stack(string item) // TODO: Do a periodic caching, like daily where the most popular items from the previous period are brought forward into the next day var currentScore = (await _cache.SortedSetScoreAsync(PopularSearchesKey, item)) ?? 0; - _ = transaction.SortedSetAddAsync(PopularSearchesKey, item, (int)currentScore + 1 + GetNormalizedTimestamp()); - _ = transaction.SortedSetRemoveRangeByRankAsync(PopularSearchesKey, 0, -(MaxPopularSearches + 1)); + _ = transaction.SortedSetAddAsync(PopularSearchesKey, item, (int)++currentScore + GetNormalizedTimestamp()); // Execute the transaction bool committed = await transaction.ExecuteAsync(); @@ -59,9 +63,8 @@ public async Task Stack(string item) static double GetNormalizedTimestamp() { - // This can be improved by addressing the time-cycle reset problem. - long unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - return (unixTimestamp % 1000000) / 1000000.0; + TimeSpan timeSinceStartDate = DateTime.Now - StartDate; + return timeSinceStartDate.TotalSeconds / 10_000_000_000; // It will take over 100 years for this value to grow to 1. } } } diff --git a/Infrastructure/Redis/SimpleRedisCache.cs b/Infrastructure/Redis/SimpleRedisCache.cs new file mode 100644 index 0000000..e1e3cf1 --- /dev/null +++ b/Infrastructure/Redis/SimpleRedisCache.cs @@ -0,0 +1,32 @@ +using Ardalis.GuardClauses; +using Core.Cache; +using Infrastructure.Configuration; +using Microsoft.Extensions.Options; +using StackExchange.Redis; +namespace Infrastructure.Redis +{ + public class SimpleRedisCache(IConnectionMultiplexer connectionMultiplexer, IOptions redisConfig) : + RedisCache(connectionMultiplexer, redisConfig), ISimpleCache + { + public async Task GetAsync(string key) + { + RedisValue theValue = await _cache.StringGetAsync(key); + + return theValue.IsNullOrEmpty ? default : ConvertToType(theValue); + } + + private static T ConvertToType(RedisValue value) + { + if(typeof(T) == typeof(DateTimeOffset)) + { + return (T)(object)DateTimeOffset.Parse(value.ToString()); + } + return (T)Convert.ChangeType(value.ToString(), typeof(T)); + } + + public async Task SetAsync(string key, T value, TimeSpan? expiry = null) + { + await _cache.StringSetAsync(key, Guard.Against.Null(value)!.ToString(), expiry); + } + } +} diff --git a/Infrastructure/Twitter/TwitterService.cs b/Infrastructure/Twitter/TwitterService.cs index 5dc8057..0677d41 100644 --- a/Infrastructure/Twitter/TwitterService.cs +++ b/Infrastructure/Twitter/TwitterService.cs @@ -2,9 +2,9 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Diagnostics; -using Microsoft.Extensions.Caching.Memory; using Hangfire; using Infrastructure.Configuration; +using Core.Cache; namespace Infrastructure.Twitter { @@ -12,13 +12,13 @@ public class TwitterService( ITwitterClientV2 twitterApiClient, ILogger logger, IOptions twitterConfig, - IMemoryCache cache, + ISimpleCache cache, IBackgroundJobClientV2 backgroundJobClient) : ITwitterService { private readonly ITwitterClientV2 _twitterApiClient = twitterApiClient; private readonly ILogger _logger = logger; private readonly TwitterConfig _twitterConfig = twitterConfig.Value; - private readonly IMemoryCache _memoryCache = cache; + private readonly ISimpleCache _simpleCache = cache; private readonly IBackgroundJobClientV2 _backgroundJobClient = backgroundJobClient; private static readonly SemaphoreSlim _semaphore; private const string LastTweetPublishedKey = "LastTweetPublished"; @@ -48,10 +48,10 @@ private async Task PostTweetAsync(string tweetText, CancellationToken cancellati await _semaphore.WaitAsync(cancellationToken); // We want to be scheduling only one tweet at a time. try { - var foundLastPublished = _memoryCache.TryGetValue(LastTweetPublishedKey, out DateTimeOffset lastTweetPublished); + var lastTweetPublished = await _simpleCache.GetAsync(LastTweetPublishedKey); var nextTweetTime = lastTweetPublished.AddSeconds(_twitterConfig.TweetIntervalSeconds); - if (foundLastPublished && nextTweetTime > DateTimeOffset.Now) + if (lastTweetPublished != default && nextTweetTime > DateTimeOffset.Now) { _backgroundJobClient.Schedule(() => SendTweetAsync(tweetText), nextTweetTime); } @@ -61,7 +61,7 @@ private async Task PostTweetAsync(string tweetText, CancellationToken cancellati _backgroundJobClient.Enqueue(() => SendTweetAsync(tweetText)); } - _memoryCache.Set(LastTweetPublishedKey, nextTweetTime); + await _simpleCache.SetAsync(LastTweetPublishedKey, nextTweetTime); } finally { From 6e2016b07a5abf2adaa7c65e5cf9cb7a5456fff0 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Sat, 2 Nov 2024 02:21:53 +0200 Subject: [PATCH 19/20] Extract reusable components from the projects (#115) * Remove unnecessary usings across all projects in the solution. * Replace name with Title. * Create new generalized core library. * Fix Create reusable Name Core and Application layers. * Change local port for API due to restricted port issue. * Remove all references to "Name" in the Dictionary entry service. * Replace all instances of "Name" in the generic entry repository. * Refactor repository logic into reusable class. * Shrink method size. * Create new reusable infrastructure project. * Remove direct reference to MongoDB driver. * Fix broken domain events. * Use one Infrastructure project for all external dependencies. * Fix broken feedback section. * Remove unused using. * Pack YorubaOrganization.Core into a nupkg. * Pack the reusable Application project into to nupkg. * Move the dictionary component projects to a different solution. * Clean up Core project file. * Fix file formatting issue. * Minor cleanups. * Fix namespaces and usings. --- Api/Api.csproj | 9 +- Api/Controllers/AuthController.cs | 4 +- Api/Controllers/EtymologyController.cs | 4 +- Api/Controllers/FeedbacksController.cs | 16 +- Api/Controllers/GeoLocationsController.cs | 6 +- Api/Controllers/NamesController.cs | 35 +- Api/Controllers/SearchController.cs | 38 +-- Api/Controllers/SuggestedNameController.cs | 1 - .../GlobalExceptionHandling.cs | 2 +- Api/Program.cs | 17 +- Api/Utilities/CustomSchemaFilter.cs | 4 +- Application/Application.csproj | 3 +- Application/Cache/ICacheService.cs | 8 +- .../DeletedNameCachingHandler.cs | 38 --- ...Handler.cs => EntryIndexedEventHandler.cs} | 7 +- .../ExactNameSearchedEventHandler.cs | 21 -- .../Events/ExactNameSearchedAdapter.cs | 12 - Application/Events/NameDeletedAdapter.cs | 11 - .../Events/NameEntryNameUpdatedAdapter.cs | 11 - Application/Events/NameIndexedAdapter.cs | 11 - .../NonExistingNameUpdateAttemptedAdapter.cs | 14 - Application/Exceptions/ClientException.cs | 23 -- Application/Exceptions/DuplicateException.cs | 9 - .../Exceptions/RepositoryAccessException.cs | 23 -- .../UnpublishedNameUpdateException.cs | 7 - Application/Mappers/NameEntryMapper.cs | 26 +- Application/Mappers/SuggestedNameMapper.cs | 2 + .../Migrator/MigrationDTOs.cs/etymology.cs | 8 +- .../Migrator/MigrationDTOs.cs/geolocation.cs | 8 +- .../Migrator/MigrationDTOs.cs/nameentry.cs | 9 +- .../MigrationDTOs.cs/nameentryfeedback.cs | 8 +- .../MigrationDTOs.cs/nameentryvideos.cs | 8 +- .../MigrationDTOs.cs/suggested_name.cs | 8 +- .../Migrator/MigrationDTOs.cs/users.cs | 8 +- Application/Migrator/SQLToMongoMigrator.cs | 57 ++-- Application/Services/BasicAuthHandler.cs | 6 +- Application/Services/EventPubService.cs | 16 +- Application/Services/GeoLocationsService.cs | 6 +- .../Services/NameEntryFeedbackService.cs | 6 +- Application/Services/NameEntryService.cs | 188 +---------- Application/Services/SearchService.cs | 27 +- Application/Services/UserService.cs | 8 +- Application/Validation/CreateUserValidator.cs | 6 +- .../Validation/EmbeddedVideoValidator.cs | 9 +- Application/Validation/EtymologyValidator.cs | 9 +- .../Validation/GeoLocationValidator.cs | 6 +- Application/Validation/UpdateUserValidator.cs | 6 +- Core/Cache/IRecentIndexesCache.cs | 6 - Core/Cache/IRecentSearchesCache.cs | 7 - Core/Cache/ISetBasedCache.cs | 15 - Core/Cache/ISimpleCache.cs | 8 - Core/Core.csproj | 3 + Core/Dto/CharacterSeparatedString.cs | 35 -- Core/Dto/CommaSeparatedString.cs | 11 - Core/Dto/HyphenSeparatedString.cs | 14 - Core/Dto/Request/CreateGeoLocationDto.cs | 6 - Core/Dto/Request/CreateNameDto.cs | 4 +- Core/Dto/Request/CreateNameFeedbackDto.cs | 2 +- Core/Dto/Request/CreateSuggestedNameDto.cs | 4 +- Core/Dto/Request/CreateUserDto.cs | 15 - Core/Dto/Request/EmbeddedVideoDto.cs | 7 - Core/Dto/Request/EtymologyDto.cs | 10 - Core/Dto/Request/NameDto.cs | 20 +- Core/Dto/Request/UpdateUserDto.cs | 7 - Core/Dto/Response/FeedbackDto.cs | 6 - Core/Dto/Response/GeoLocationDto.cs | 10 - Core/Dto/Response/NameEntryDto.cs | 6 +- Core/Dto/Response/NameFeedbackDto.cs | 6 + Core/Dto/Response/RecentStats.cs | 23 -- Core/Dto/Response/SearchMetadataDto.cs | 7 - Core/Dto/Response/SuggestedNameDto.cs | 4 +- Core/Dto/Response/UserDto.cs | 9 - Core/Entities/BaseEntity.cs | 20 -- Core/Entities/GeoLocation.cs | 20 -- Core/Entities/NameEntry.cs | 16 + .../NameEntry/Collections/EmbeddedVideo.cs | 14 - .../NameEntry/Collections/Etymology.cs | 13 - .../NameEntry/Collections/Feedback.cs | 18 - Core/Entities/NameEntry/NameEntry.cs | 94 ------ Core/Entities/SuggestedName.cs | 4 +- Core/Entities/User.cs | 15 - Core/Enums/Role.cs | 15 - Core/Enums/State.cs | 9 - Core/Events/ExactNameSearched.cs | 7 - Core/Events/IEventPubService.cs | 7 - Core/Events/NameDeleted.cs | 5 - Core/Events/NameEntryNameUpdated.cs | 6 - Core/Events/NameIndexed.cs | 6 +- Core/Events/NonExistingNameUpdateAttempted.cs | 6 - Core/Repositories/IEtymologyRepository.cs | 7 - Core/Repositories/IGeoLocationsRepository.cs | 14 - .../INameEntryFeedbackRepository.cs | 7 +- Core/Repositories/INameEntryRepository.cs | 53 +-- Core/Repositories/IUserRepository.cs | 16 - .../CharacterSeparatedStringConverter.cs | 2 +- .../CommaSeparatedStringConverter.cs | 2 +- .../HyphenSeparatedStringConverter.cs | 2 +- Core/Utilities/DiacriticsNormalizer.cs | 74 ---- .../Infrastructure.MongoDB.csproj | 14 - .../Repositories/MongoDBRepository.cs | 18 - .../Repositories/NameEntryRepository.cs | 316 ------------------ .../Hangfire/DependencyInjection.cs | 1 - Infrastructure/Hangfire/HangfireAuthFilter.cs | 2 +- Infrastructure/Infrastructure.csproj | 10 +- .../MongoDB}/DependencyInjection.cs | 1 + .../Repositories/EtymologyRepository.cs | 9 +- .../Repositories/GeoLocationsRepository.cs | 7 +- .../NameEntryFeedbackRepository.cs | 43 +-- .../Repositories/NameEntryRepository.cs | 34 ++ .../Repositories/SuggestedNameRepository.cs | 1 - .../MongoDB}/Repositories/UserRepository.cs | 11 +- Infrastructure/Redis/DependencyInjection.cs | 2 +- .../Redis/RedisRecentIndexesCache.cs | 4 +- .../Redis/RedisRecentSearchesCache.cs | 4 +- Infrastructure/Redis/SimpleRedisCache.cs | 2 +- Infrastructure/TweetsV2Poster.cs | 1 - Infrastructure/Twitter/TwitterService.cs | 2 +- README.md | 2 +- .../NameController/Data/NamesAllTestData.cs | 4 +- .../Data/NamesCountAndSubmittedByTestData.cs | 2 +- .../NameController/Data/NamesCountTestData.cs | 6 +- .../Data/NamesStateAndCountTestData.cs | 4 +- .../Data/NamesStateAndSubmittedByTestData.cs | 4 +- .../NamesStateCountAndSubmittedByTestData.cs | 4 +- .../NameController/Data/NamesStateTestData.cs | 4 +- .../Data/NamesSubmittedByTestData.cs | 4 +- .../NameController/NameControllerTest.cs | 20 +- Website/Pages/SearchResults.cshtml.cs | 1 - Website/Pages/Shared/BasePageModel.cshtml.cs | 1 - Website/Pages/SubmitName.cshtml.cs | 3 +- Website/Services/ApiService.cs | 1 + YorubaNameDictionary.sln | 8 +- docker-compose.override.yml | 2 +- 133 files changed, 382 insertions(+), 1636 deletions(-) delete mode 100644 Application/EventHandlers/DeletedNameCachingHandler.cs rename Application/EventHandlers/{NameIndexedEventHandler.cs => EntryIndexedEventHandler.cs} (81%) delete mode 100644 Application/EventHandlers/ExactNameSearchedEventHandler.cs delete mode 100644 Application/Events/ExactNameSearchedAdapter.cs delete mode 100644 Application/Events/NameDeletedAdapter.cs delete mode 100644 Application/Events/NameEntryNameUpdatedAdapter.cs delete mode 100644 Application/Events/NameIndexedAdapter.cs delete mode 100644 Application/Events/NonExistingNameUpdateAttemptedAdapter.cs delete mode 100644 Application/Exceptions/ClientException.cs delete mode 100644 Application/Exceptions/DuplicateException.cs delete mode 100644 Application/Exceptions/RepositoryAccessException.cs delete mode 100644 Application/Exceptions/UnpublishedNameUpdateException.cs delete mode 100644 Core/Cache/IRecentIndexesCache.cs delete mode 100644 Core/Cache/IRecentSearchesCache.cs delete mode 100644 Core/Cache/ISetBasedCache.cs delete mode 100644 Core/Cache/ISimpleCache.cs delete mode 100644 Core/Dto/CharacterSeparatedString.cs delete mode 100644 Core/Dto/CommaSeparatedString.cs delete mode 100644 Core/Dto/HyphenSeparatedString.cs delete mode 100644 Core/Dto/Request/CreateGeoLocationDto.cs delete mode 100644 Core/Dto/Request/CreateUserDto.cs delete mode 100644 Core/Dto/Request/EmbeddedVideoDto.cs delete mode 100644 Core/Dto/Request/EtymologyDto.cs delete mode 100644 Core/Dto/Request/UpdateUserDto.cs delete mode 100644 Core/Dto/Response/FeedbackDto.cs delete mode 100644 Core/Dto/Response/GeoLocationDto.cs create mode 100644 Core/Dto/Response/NameFeedbackDto.cs delete mode 100644 Core/Dto/Response/RecentStats.cs delete mode 100644 Core/Dto/Response/SearchMetadataDto.cs delete mode 100644 Core/Dto/Response/UserDto.cs delete mode 100644 Core/Entities/BaseEntity.cs delete mode 100644 Core/Entities/GeoLocation.cs create mode 100644 Core/Entities/NameEntry.cs delete mode 100644 Core/Entities/NameEntry/Collections/EmbeddedVideo.cs delete mode 100644 Core/Entities/NameEntry/Collections/Etymology.cs delete mode 100644 Core/Entities/NameEntry/Collections/Feedback.cs delete mode 100644 Core/Entities/NameEntry/NameEntry.cs delete mode 100644 Core/Entities/User.cs delete mode 100644 Core/Enums/Role.cs delete mode 100644 Core/Enums/State.cs delete mode 100644 Core/Events/ExactNameSearched.cs delete mode 100644 Core/Events/IEventPubService.cs delete mode 100644 Core/Events/NameDeleted.cs delete mode 100644 Core/Events/NameEntryNameUpdated.cs delete mode 100644 Core/Events/NonExistingNameUpdateAttempted.cs delete mode 100644 Core/Repositories/IEtymologyRepository.cs delete mode 100644 Core/Repositories/IGeoLocationsRepository.cs delete mode 100644 Core/Repositories/IUserRepository.cs delete mode 100644 Core/Utilities/DiacriticsNormalizer.cs delete mode 100644 Infrastructure.MongoDB/Infrastructure.MongoDB.csproj delete mode 100644 Infrastructure.MongoDB/Repositories/MongoDBRepository.cs delete mode 100644 Infrastructure.MongoDB/Repositories/NameEntryRepository.cs rename {Infrastructure.MongoDB => Infrastructure/MongoDB}/DependencyInjection.cs (96%) rename {Infrastructure.MongoDB => Infrastructure/MongoDB}/Repositories/EtymologyRepository.cs (86%) rename {Infrastructure.MongoDB => Infrastructure/MongoDB}/Repositories/GeoLocationsRepository.cs (97%) rename {Infrastructure.MongoDB => Infrastructure/MongoDB}/Repositories/NameEntryFeedbackRepository.cs (63%) create mode 100644 Infrastructure/MongoDB/Repositories/NameEntryRepository.cs rename {Infrastructure.MongoDB => Infrastructure/MongoDB}/Repositories/SuggestedNameRepository.cs (99%) rename {Infrastructure.MongoDB => Infrastructure/MongoDB}/Repositories/UserRepository.cs (93%) diff --git a/Api/Api.csproj b/Api/Api.csproj index 7a53800..74b1ab7 100644 --- a/Api/Api.csproj +++ b/Api/Api.csproj @@ -8,14 +8,13 @@ ..\docker-compose.dcproj - - - - + + + + - \ No newline at end of file diff --git a/Api/Controllers/AuthController.cs b/Api/Controllers/AuthController.cs index f181c34..c465d1b 100644 --- a/Api/Controllers/AuthController.cs +++ b/Api/Controllers/AuthController.cs @@ -1,11 +1,11 @@ using Api.Utilities; using Application.Services; -using Core.Dto.Request; -using Core.Dto.Response; using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Net; +using YorubaOrganization.Core.Dto.Request; +using YorubaOrganization.Core.Dto.Response; namespace Api.Controllers { diff --git a/Api/Controllers/EtymologyController.cs b/Api/Controllers/EtymologyController.cs index ce72345..e99ed18 100644 --- a/Api/Controllers/EtymologyController.cs +++ b/Api/Controllers/EtymologyController.cs @@ -1,5 +1,5 @@ -using Core.Repositories; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; +using YorubaOrganization.Core.Repositories; namespace Api.Controllers { diff --git a/Api/Controllers/FeedbacksController.cs b/Api/Controllers/FeedbacksController.cs index f69266b..1d22050 100644 --- a/Api/Controllers/FeedbacksController.cs +++ b/Api/Controllers/FeedbacksController.cs @@ -1,8 +1,6 @@ -using Api.Model.Request; -using Application.Domain; -using Application.Services; +using Application.Services; +using Core.Dto.Request; using Core.Dto.Response; -using Core.Entities.NameEntry.Collections; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.ComponentModel.DataAnnotations; @@ -27,7 +25,7 @@ public FeedbacksController(NameEntryFeedbackService nameEntryFeedbackService, Na [HttpGet] [Route("{id}")] - [ProducesResponseType(typeof(FeedbackDto), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(NameFeedbackDto), (int)HttpStatusCode.OK)] public async Task GetById(string id) { var feedback = await _nameEntryFeedbackService.GetFeedbackByIdAsync(id); @@ -46,7 +44,7 @@ public async Task GetById(string id) /// Optional name parameter /// A list of all feedback (or just feedback for a given name) [HttpGet] - [ProducesResponseType(typeof(List), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(List), (int)HttpStatusCode.OK)] public async Task GetFeedback([FromQuery] string? name = null) { var feedbacks = string.IsNullOrWhiteSpace(name) ? await _nameEntryFeedbackService.FindAllAsync() : @@ -64,7 +62,7 @@ public async Task GetFeedback([FromQuery] string? name = null) [ProducesResponseType(typeof(Dictionary), (int)HttpStatusCode.Created)] public async Task Create([FromBody] CreateNameFeedbackDto model) { - var nameEntry = await _nameEntryService.LoadName(model.Name); + var nameEntry = await _nameEntryService.LoadEntry(model.Name); if (nameEntry == null) { @@ -91,7 +89,7 @@ public async Task Delete(string name, string feedbackId) return BadRequest("Name parameter is required."); } - var nameEntry = await _nameEntryService.LoadName(name); + var nameEntry = await _nameEntryService.LoadEntry(name); if (nameEntry == null) { @@ -122,7 +120,7 @@ public async Task DeleteAll([FromQuery][Required] string name) return BadRequest("Name parameter is required."); } - var nameEntry = await _nameEntryService.LoadName(name); + var nameEntry = await _nameEntryService.LoadEntry(name); if (nameEntry == null) { diff --git a/Api/Controllers/GeoLocationsController.cs b/Api/Controllers/GeoLocationsController.cs index 8d4c5a9..ba0cfd8 100644 --- a/Api/Controllers/GeoLocationsController.cs +++ b/Api/Controllers/GeoLocationsController.cs @@ -1,11 +1,11 @@ using Api.Utilities; using Application.Services; -using Core.Dto.Request; -using Core.Dto.Response; -using Core.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Net; +using YorubaOrganization.Core.Dto.Request; +using YorubaOrganization.Core.Dto.Response; +using YorubaOrganization.Core.Entities; namespace Api.Controllers { diff --git a/Api/Controllers/NamesController.cs b/Api/Controllers/NamesController.cs index 5ea13a7..ebfd54c 100644 --- a/Api/Controllers/NamesController.cs +++ b/Api/Controllers/NamesController.cs @@ -1,15 +1,15 @@ -using Api.Model.In; -using Api.Utilities; -using Application.Domain; +using Api.Utilities; using Application.Mappers; +using Application.Services; using Core.Dto.Request; using Core.Dto.Response; -using Core.Entities.NameEntry; -using Core.Enums; +using Core.Entities; using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using MongoDB.Driver; using System.Net; +using YorubaOrganization.Core.Enums; namespace Api.Controllers { @@ -63,13 +63,18 @@ public async Task Post([FromBody] CreateNameDto request) /// /// [HttpGet("meta")] - [ProducesResponseType(typeof(NamesMetadataDto[]), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(NamesMetadataDto), (int)HttpStatusCode.OK)] [AllowAnonymous] public async Task GetMetaData() { var metaData = await _nameEntryService.GetMetadata(); - - return Ok(metaData); + return Ok(new NamesMetadataDto + { + TotalNames = metaData.TotalEntries, + TotalPublishedNames = metaData.TotalPublishedEntries, + TotalModifiedNames = metaData.TotalModifiedEntries, + TotalNewNames = metaData.TotalNewEntries + }); } /// @@ -96,7 +101,7 @@ public async Task GetAllNames( IEnumerable names; if (all.HasValue && all.Value) { - names = await _nameEntryService.GetAllNames(state, submittedBy); + names = await _nameEntryService.GetAllEntries(state, submittedBy); return Ok(names.MapToDtoCollectionMini()); } @@ -116,7 +121,7 @@ public async Task GetAllNames( [AllowAnonymous] public async Task GetName([FromRoute] string name) { - var nameEntry = await _nameEntryService.LoadName(name); + var nameEntry = await _nameEntryService.LoadEntry(name); if (nameEntry == null) { @@ -146,14 +151,14 @@ public async Task UpdateName(string name, [FromBody] UpdateNameDt return BadRequest(ModelState); } - var oldNameEntry = await _nameEntryService.LoadName(name); + var oldNameEntry = await _nameEntryService.LoadEntry(name); if (oldNameEntry == null) { return NotFound($"{name} not in database"); } - _ = await _nameEntryService.UpdateName(oldNameEntry, updated.MapToEntity()); + _ = await _nameEntryService.Update(oldNameEntry, updated.MapToEntity()); return Ok(new { Message = "Name successfully updated" }); } @@ -161,7 +166,7 @@ public async Task UpdateName(string name, [FromBody] UpdateNameDt [Authorize(Policy = "AdminOnly")] public async Task DeleteName(string name) { - var nameEntry = await _nameEntryService.LoadName(name); + var nameEntry = await _nameEntryService.LoadEntry(name); if (nameEntry == null) { return NotFound($"{name} not found in the system so cannot be deleted"); @@ -177,7 +182,7 @@ public async Task DeleteName(string name) public async Task DeleteNamesBatch(string[] names) { - var foundNames = (await _nameEntryService.LoadNames(names))?.Select(f => f.Name)?.ToArray(); + var foundNames = (await _nameEntryService.LoadEntries(names))?.Select(f => f.Title)?.ToArray(); if (foundNames is null || foundNames.Length == 0) { @@ -186,7 +191,7 @@ public async Task DeleteNamesBatch(string[] names) var notFoundNames = names.Where(d => !foundNames.Contains(d)).ToList(); - await _nameEntryService.DeleteNamesBatch(foundNames); + await _nameEntryService.DeleteEntriesBatch(foundNames); string responseMessage = string.Join(", ", foundNames) + " deleted"; if (notFoundNames.Any()) diff --git a/Api/Controllers/SearchController.cs b/Api/Controllers/SearchController.cs index 90714fa..c3b6ce2 100644 --- a/Api/Controllers/SearchController.cs +++ b/Api/Controllers/SearchController.cs @@ -1,16 +1,16 @@ using Api.Utilities; -using Application.Domain; using Application.Mappers; using Application.Services; -using Core.Cache; using Core.Dto.Response; -using Core.Entities.NameEntry; -using Core.Enums; -using Core.Events; +using Core.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.ComponentModel.DataAnnotations; using System.Net; +using YorubaOrganization.Core.Cache; +using YorubaOrganization.Core.Dto.Response; +using YorubaOrganization.Core.Enums; +using YorubaOrganization.Core.Events; namespace Api.Controllers { @@ -55,9 +55,9 @@ public async Task Search([FromQuery(Name = "q"), Required] string var matches = await _searchService.Search(searchTerm); // TODO: Check if the comparison here removes takes diacrits into consideration - if (matches.Count() == 1 && matches.First().Name.Equals(searchTerm, StringComparison.CurrentCultureIgnoreCase)) + if (matches.Count() == 1 && matches.First().Title.Equals(searchTerm, StringComparison.CurrentCultureIgnoreCase)) { - await _eventPubService.PublishEvent(new ExactNameSearched(matches.First().Name)); + await _eventPubService.PublishEvent(new ExactEntrySearched(matches.First().Title)); } return Ok(matches.MapToDtoCollection()); @@ -92,11 +92,11 @@ public async Task SearchByStartsWith(string searchTerm) [ProducesResponseType(typeof(NameEntryDto), (int)HttpStatusCode.OK)] public async Task SearchOne(string searchTerm) { - NameEntryDto nameEntry = await _searchService.GetName(searchTerm); + var nameEntry = await _searchService.GetName(searchTerm); if(nameEntry != null) { - await _eventPubService.PublishEvent(new ExactNameSearched(nameEntry.Name)); + await _eventPubService.PublishEvent(new ExactEntrySearched(nameEntry.Name)); } return Ok(nameEntry); @@ -158,7 +158,7 @@ public async Task GetRecentStats() [Authorize(Policy = "AdminAndProLexicographers")] public async Task PublishName([FromRoute] string name) { - var nameEntry = await _nameEntryService.LoadName(name); + var nameEntry = await _nameEntryService.LoadEntry(name); if (nameEntry == null) { return BadRequest(ResponseHelper.GetResponseDict($"{name} not found in the repository so not indexed")); @@ -170,7 +170,7 @@ public async Task PublishName([FromRoute] string name) return BadRequest(ResponseHelper.GetResponseDict($"{name} is already indexed")); } - await _nameEntryService.PublishName(nameEntry, User!.Identity!.Name!); + await _nameEntryService.PublishEntry(nameEntry, User!.Identity!.Name!); return StatusCode((int)HttpStatusCode.Created, ResponseHelper.GetResponseDict($"{name} has been published")); } @@ -189,7 +189,7 @@ public async Task PublishNames([FromBody] string[] names) // TODO Later: Optimize by fetching all names in one database call instead of one-by-one. foreach (var name in names) { - var entry = await _nameEntryService.LoadName(name); + var entry = await _nameEntryService.LoadEntry(name); if (entry != null && entry.State != State.PUBLISHED) { entriesToIndex.Add(entry); @@ -204,10 +204,10 @@ public async Task PublishNames([FromBody] string[] names) foreach (var nameEntry in entriesToIndex) { // TODO Later: The names should be updated in one batch instead of one-by-one. - await _nameEntryService.PublishName(nameEntry, User!.Identity!.Name!); + await _nameEntryService.PublishEntry(nameEntry, User!.Identity!.Name!); } - var successMessage = $"The following names were successfully indexed: {string.Join(',', entriesToIndex.Select(x => x.Name))}"; + var successMessage = $"The following names were successfully indexed: {string.Join(',', entriesToIndex.Select(x => x.Title))}"; return StatusCode((int)HttpStatusCode.Created, ResponseHelper.GetResponseDict(successMessage)); } @@ -220,7 +220,7 @@ public async Task PublishNames([FromBody] string[] names) [Authorize(Policy = "AdminAndProLexicographers")] public async Task UnpublishName([FromRoute] string name) { - var entry = await _nameEntryService.FindByNameAndState(name, State.PUBLISHED); + var entry = await _nameEntryService.FindByTitleAndState(name, State.PUBLISHED); if (entry == null) { @@ -228,7 +228,7 @@ public async Task UnpublishName([FromRoute] string name) } entry.State = State.NEW; - await _nameEntryService.UpdateName(entry); + await _nameEntryService.Update(entry); return Ok(ResponseHelper.GetResponseDict($"{name} removed from index.")); } @@ -245,7 +245,7 @@ public async Task UnpublishNames([FromBody] string[] names) foreach (var name in names) { - var entry = await _nameEntryService.FindByNameAndState(name, State.PUBLISHED); + var entry = await _nameEntryService.FindByTitleAndState(name, State.PUBLISHED); if (entry == null) { @@ -254,8 +254,8 @@ public async Task UnpublishNames([FromBody] string[] names) else { entry.State = State.UNPUBLISHED; - await _nameEntryService.UpdateName(entry); - unpublishedNames.Add(entry.Name); + await _nameEntryService.Update(entry); + unpublishedNames.Add(entry.Title); } } diff --git a/Api/Controllers/SuggestedNameController.cs b/Api/Controllers/SuggestedNameController.cs index ffb56af..c4bf24e 100644 --- a/Api/Controllers/SuggestedNameController.cs +++ b/Api/Controllers/SuggestedNameController.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Mvc; using System.Net; using Application.Mappers; -using Application.Validation; using FluentValidation; namespace Api.Controllers; diff --git a/Api/ExceptionHandler/GlobalExceptionHandling.cs b/Api/ExceptionHandler/GlobalExceptionHandling.cs index 3f18d1c..f73cc7b 100644 --- a/Api/ExceptionHandler/GlobalExceptionHandling.cs +++ b/Api/ExceptionHandler/GlobalExceptionHandling.cs @@ -1,9 +1,9 @@ namespace Api.ExceptionHandler { using Api.Utilities; - using Application.Exceptions; using System.Net; using System.Text.Json; + using YorubaOrganization.Application.Exceptions; public class GlobalExceptionHandlingMiddleware { diff --git a/Api/Program.cs b/Api/Program.cs index d8f4f4b..da675e0 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -1,16 +1,10 @@ using Api.ExceptionHandler; -using Application.Domain; -using Application.Events; using Application.Migrator; using Application.Services; using Application.Validation; -using Core.Cache; -using Core.Enums; -using Core.Events; using Core.StringObjectConverters; using FluentValidation; using Infrastructure.Twitter; -using Infrastructure.MongoDB; using Microsoft.AspNetCore.Authentication; using Microsoft.OpenApi.Models; using MySqlConnector; @@ -19,6 +13,11 @@ using Infrastructure.Hangfire; using Infrastructure.Redis; using Ardalis.GuardClauses; +using YorubaOrganization.Core.Enums; +using YorubaOrganization.Core.Events; +using YorubaOrganization.Core.Cache; +using Application.EventHandlers; +using Infrastructure.MongoDB; var builder = WebApplication.CreateBuilder(args); var configuration = builder.Configuration; @@ -119,7 +118,11 @@ //Validation services.AddValidatorsFromAssemblyContaining(); -services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ExactNameSearchedAdapter).Assembly)); +services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssembly(typeof(PostPublishedNameCommandHandler).Assembly); + cfg.RegisterServicesFromAssembly(typeof(ExactEntrySearchedEventHandler).Assembly); +}); // Twitter integration configuration services.AddSingleton(); diff --git a/Api/Utilities/CustomSchemaFilter.cs b/Api/Utilities/CustomSchemaFilter.cs index 84153ed..863f8ae 100644 --- a/Api/Utilities/CustomSchemaFilter.cs +++ b/Api/Utilities/CustomSchemaFilter.cs @@ -1,6 +1,6 @@ -using Core.Dto; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; +using YorubaOrganization.Core.Dto; public class CustomSchemaFilter : ISchemaFilter { diff --git a/Application/Application.csproj b/Application/Application.csproj index 1310f6a..e4d82e4 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -10,12 +10,13 @@ + - + diff --git a/Application/Cache/ICacheService.cs b/Application/Cache/ICacheService.cs index 8cc97c4..2c38165 100644 --- a/Application/Cache/ICacheService.cs +++ b/Application/Cache/ICacheService.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Application.Cache +namespace Application.Cache { public interface ICacheService { diff --git a/Application/EventHandlers/DeletedNameCachingHandler.cs b/Application/EventHandlers/DeletedNameCachingHandler.cs deleted file mode 100644 index 79df525..0000000 --- a/Application/EventHandlers/DeletedNameCachingHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Application.Events; -using Core.Cache; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace Application.EventHandlers -{ - public class DeletedNameCachingHandler : INotificationHandler - { - private readonly IRecentIndexesCache _recentIndexesCache; - private readonly IRecentSearchesCache _recentSearchesCache; - private readonly ILogger _logger; - - public DeletedNameCachingHandler( - IRecentIndexesCache recentIndexesCache, - IRecentSearchesCache recentSearchesCache, - ILogger logger - ) - { - _recentIndexesCache = recentIndexesCache; - _recentSearchesCache = recentSearchesCache; - _logger = logger; - } - - public async Task Handle(NameDeletedAdapter notification, CancellationToken cancellationToken) - { - try - { - await _recentIndexesCache.Remove(notification.Name); - await _recentSearchesCache.Remove(notification.Name); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while removing deleted name '{name}' from cache.", notification.Name); - } - } - } -} \ No newline at end of file diff --git a/Application/EventHandlers/NameIndexedEventHandler.cs b/Application/EventHandlers/EntryIndexedEventHandler.cs similarity index 81% rename from Application/EventHandlers/NameIndexedEventHandler.cs rename to Application/EventHandlers/EntryIndexedEventHandler.cs index dc09fc1..fbf9ace 100644 --- a/Application/EventHandlers/NameIndexedEventHandler.cs +++ b/Application/EventHandlers/EntryIndexedEventHandler.cs @@ -1,10 +1,11 @@ using Application.Events; -using Core.Cache; +using Core.Events; using MediatR; +using YorubaOrganization.Core.Cache; namespace Application.EventHandlers { - public class NameIndexedEventHandler : INotificationHandler + public class NameIndexedEventHandler : INotificationHandler { public IRecentIndexesCache _recentIndexesCache; private readonly IMediator _mediator; @@ -17,7 +18,7 @@ public NameIndexedEventHandler( _mediator = mediator; } - public async Task Handle(NameIndexedAdapter notification, CancellationToken cancellationToken) + public async Task Handle(NameIndexed notification, CancellationToken cancellationToken) { await _recentIndexesCache.Stack(notification.Name); await _mediator.Publish(new PostPublishedNameCommand(notification.Name, notification.Meaning), cancellationToken); diff --git a/Application/EventHandlers/ExactNameSearchedEventHandler.cs b/Application/EventHandlers/ExactNameSearchedEventHandler.cs deleted file mode 100644 index a7685b1..0000000 --- a/Application/EventHandlers/ExactNameSearchedEventHandler.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Application.Events; -using Core.Cache; -using MediatR; - -namespace Application.EventHandlers -{ - public class ExactNameSearchedEventHandler : INotificationHandler - { - public IRecentSearchesCache _recentSearchesCache; - - public ExactNameSearchedEventHandler(IRecentSearchesCache recentSearchesCache) - { - _recentSearchesCache = recentSearchesCache; - } - - public async Task Handle(ExactNameSearchedAdapter notification, CancellationToken cancellationToken) - { - await _recentSearchesCache.Stack(notification.SearchTerm); - } - } -} diff --git a/Application/Events/ExactNameSearchedAdapter.cs b/Application/Events/ExactNameSearchedAdapter.cs deleted file mode 100644 index 21df021..0000000 --- a/Application/Events/ExactNameSearchedAdapter.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Core.Events; -using MediatR; - -namespace Application.Events -{ - public record ExactNameSearchedAdapter : ExactNameSearched, INotification - { - public ExactNameSearchedAdapter(ExactNameSearched theEvent) : base(theEvent.SearchTerm) - { - } - } -} \ No newline at end of file diff --git a/Application/Events/NameDeletedAdapter.cs b/Application/Events/NameDeletedAdapter.cs deleted file mode 100644 index a91e513..0000000 --- a/Application/Events/NameDeletedAdapter.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Core.Events; -using MediatR; - -namespace Application.Events; - -public record NameDeletedAdapter : NameDeleted, INotification -{ - public NameDeletedAdapter(NameDeleted theEvent) : base(theEvent.Name) - { - } -} \ No newline at end of file diff --git a/Application/Events/NameEntryNameUpdatedAdapter.cs b/Application/Events/NameEntryNameUpdatedAdapter.cs deleted file mode 100644 index 74821fd..0000000 --- a/Application/Events/NameEntryNameUpdatedAdapter.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Core.Events; -using MediatR; - -namespace Application.Events; - -public record NameEntryNameUpdatedAdapter : NameEntryNameUpdated, INotification -{ - public NameEntryNameUpdatedAdapter(NameEntryNameUpdated theEvent) : base(theEvent.OriginalName, theEvent.NewName) - { - } -} \ No newline at end of file diff --git a/Application/Events/NameIndexedAdapter.cs b/Application/Events/NameIndexedAdapter.cs deleted file mode 100644 index 10c9f84..0000000 --- a/Application/Events/NameIndexedAdapter.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Core.Events; -using MediatR; - -namespace Application.Events; - -public record NameIndexedAdapter : NameIndexed, INotification -{ - public NameIndexedAdapter(NameIndexed theEvent) : base(theEvent.Name, theEvent.Meaning) - { - } -} \ No newline at end of file diff --git a/Application/Events/NonExistingNameUpdateAttemptedAdapter.cs b/Application/Events/NonExistingNameUpdateAttemptedAdapter.cs deleted file mode 100644 index 1d043d9..0000000 --- a/Application/Events/NonExistingNameUpdateAttemptedAdapter.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Core.Events; -using MediatR; - -namespace Application.Events -{ - public record NonExistingNameUpdateAttemptedAdapter : NonExistingNameUpdateAttempted, INotification - { - public NonExistingNameUpdateAttemptedAdapter(string name) : base(name) { } - - public NonExistingNameUpdateAttemptedAdapter(NonExistingNameUpdateAttempted theEvent) : base(theEvent.Name) - { - } - } -} \ No newline at end of file diff --git a/Application/Exceptions/ClientException.cs b/Application/Exceptions/ClientException.cs deleted file mode 100644 index 6b08495..0000000 --- a/Application/Exceptions/ClientException.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Application.Exceptions -{ - /// - /// TODO: This should be a 400 Bad Request exception - /// - public class ClientException : Exception - { - public ClientException() - { - } - - public ClientException(string message) - : base(message) - { - } - - public ClientException(string message, Exception inner) - : base(message, inner) - { - } - } - -} diff --git a/Application/Exceptions/DuplicateException.cs b/Application/Exceptions/DuplicateException.cs deleted file mode 100644 index 976451d..0000000 --- a/Application/Exceptions/DuplicateException.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Application.Exceptions -{ - public class DuplicateException : ClientException - { - public DuplicateException() : base() { } - - public DuplicateException(string message) : base(message) { } - } -} diff --git a/Application/Exceptions/RepositoryAccessException.cs b/Application/Exceptions/RepositoryAccessException.cs deleted file mode 100644 index 64abb84..0000000 --- a/Application/Exceptions/RepositoryAccessException.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Application.Exceptions -{ - /// - /// TODO: This should be a 400 Bad Request exception - /// - public class RepositoryAccessException : Exception - { - public RepositoryAccessException() - { - } - - public RepositoryAccessException(string message) - : base(message) - { - } - - public RepositoryAccessException(string message, Exception inner) - : base(message, inner) - { - } - } - -} diff --git a/Application/Exceptions/UnpublishedNameUpdateException.cs b/Application/Exceptions/UnpublishedNameUpdateException.cs deleted file mode 100644 index bcf5def..0000000 --- a/Application/Exceptions/UnpublishedNameUpdateException.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Application.Exceptions -{ - public class UnpublishedNameUpdateException : ClientException - { - public UnpublishedNameUpdateException() : base("There is an unpublished update on this name already") { } - } -} diff --git a/Application/Mappers/NameEntryMapper.cs b/Application/Mappers/NameEntryMapper.cs index 57f9200..990b7f8 100644 --- a/Application/Mappers/NameEntryMapper.cs +++ b/Application/Mappers/NameEntryMapper.cs @@ -1,10 +1,12 @@ -using Core.Dto; -using Core.Dto.Request; +using Core.Dto.Request; using Core.Dto.Response; using Core.Entities; -using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; -using Core.Enums; +using YorubaOrganization.Core.Dto; +using YorubaOrganization.Core.Dto.Request; +using YorubaOrganization.Core.Dto.Response; +using YorubaOrganization.Core.Entities; +using YorubaOrganization.Core.Entities.Partials; +using YorubaOrganization.Core.Enums; namespace Application.Mappers { @@ -19,7 +21,7 @@ public static NameEntryMiniDto[] MapToDtoCollectionMini(this IEnumerable new NameEntryMiniDto { - Name = nameEntry.Name, + Name = nameEntry.Title, Meaning = nameEntry.Meaning, SubmittedBy = nameEntry.CreatedBy }).ToArray(); @@ -29,12 +31,12 @@ public static NameEntry MapToEntity(this NameDto request) { return new NameEntry { - Name = request.Name.Trim(), + Title = request.Name.Trim(), Pronunciation = request.Pronunciation?.Trim(), Meaning = request.Meaning.Trim(), ExtendedMeaning = request.ExtendedMeaning?.Trim(), Morphology = request.Morphology ?? new List(), - Media = request.Media ?? new List(), + MediaLinks = request.MediaLinks, State = request.State ?? State.NEW, Etymology = request.Etymology.Select(et => new Etymology(et.Part, et.Meaning)).ToList(), Videos = request.Videos.Select(ev => new EmbeddedVideo(ev.VideoId, ev.Caption)).ToList(), @@ -42,7 +44,7 @@ public static NameEntry MapToEntity(this NameDto request) GeoLocation = request.GeoLocation.Select(ge => new GeoLocation(ge.Place, ge.Region)).ToList(), FamousPeople = request.FamousPeople ?? new List(), Syllables = request.Syllables ?? new List(), - Variants = request.Variants ?? new List(), + VariantsV2 = request.VariantsV2, CreatedBy = request.SubmittedBy, UpdatedBy = request.SubmittedBy }; @@ -54,21 +56,21 @@ public static NameEntryDto MapToDto(this NameEntry nameEntry) { Pronunciation = nameEntry.Pronunciation, IpaNotation = nameEntry.IpaNotation, - Variants = (CommaSeparatedString)nameEntry.Variants, + Variants = (CommaSeparatedString)nameEntry.VariantsV2.Select(v => v.Title).ToList(), Syllables = (HyphenSeparatedString)nameEntry.Syllables, Meaning = nameEntry.Meaning, ExtendedMeaning = nameEntry.ExtendedMeaning, Morphology = (CommaSeparatedString)nameEntry.Morphology, GeoLocation = nameEntry.GeoLocation.Select(ge => new GeoLocationDto(ge.Id, ge.Place, ge.Region)).ToList(), FamousPeople = (CommaSeparatedString)nameEntry.FamousPeople, - Media = (CommaSeparatedString)nameEntry.Media, + Media = (CommaSeparatedString)nameEntry.MediaLinks.Select(m => m.Url).ToList(), SubmittedBy = nameEntry.CreatedBy, Etymology = nameEntry.Etymology.Select(et => new EtymologyDto(et.Part, et.Meaning)).ToList(), Videos = nameEntry.Videos.Select(v => new EmbeddedVideoDto(v.VideoId, v.Caption)).ToList(), State = nameEntry.State, CreatedAt = nameEntry.CreatedAt, UpdatedAt = nameEntry.UpdatedAt, - Name = nameEntry.Name + Name = nameEntry.Title }; } } diff --git a/Application/Mappers/SuggestedNameMapper.cs b/Application/Mappers/SuggestedNameMapper.cs index b26dc52..40f6482 100644 --- a/Application/Mappers/SuggestedNameMapper.cs +++ b/Application/Mappers/SuggestedNameMapper.cs @@ -2,6 +2,8 @@ using Core.Dto.Response; using Core.Entities; using MongoDB.Bson; +using YorubaOrganization.Core.Dto.Response; +using YorubaOrganization.Core.Entities; namespace Application.Mappers; diff --git a/Application/Migrator/MigrationDTOs.cs/etymology.cs b/Application/Migrator/MigrationDTOs.cs/etymology.cs index 99119ed..0177c2b 100644 --- a/Application/Migrator/MigrationDTOs.cs/etymology.cs +++ b/Application/Migrator/MigrationDTOs.cs/etymology.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Application.Migrator.MigrationDTOs.cs +namespace Application.Migrator.MigrationDTOs.cs { internal class etymology { diff --git a/Application/Migrator/MigrationDTOs.cs/geolocation.cs b/Application/Migrator/MigrationDTOs.cs/geolocation.cs index 6ac1bee..241c428 100644 --- a/Application/Migrator/MigrationDTOs.cs/geolocation.cs +++ b/Application/Migrator/MigrationDTOs.cs/geolocation.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Application.Migrator.MigrationDTOs.cs +namespace Application.Migrator.MigrationDTOs.cs { internal class geolocation { diff --git a/Application/Migrator/MigrationDTOs.cs/nameentry.cs b/Application/Migrator/MigrationDTOs.cs/nameentry.cs index ead2cc8..0a430e8 100644 --- a/Application/Migrator/MigrationDTOs.cs/nameentry.cs +++ b/Application/Migrator/MigrationDTOs.cs/nameentry.cs @@ -1,10 +1,5 @@ -using Core.Entities; -using Core.Entities.NameEntry.Collections; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using YorubaOrganization.Core.Entities; +using YorubaOrganization.Core.Entities.Partials; namespace Application.Migrator.MigrationDTOs.cs { diff --git a/Application/Migrator/MigrationDTOs.cs/nameentryfeedback.cs b/Application/Migrator/MigrationDTOs.cs/nameentryfeedback.cs index a3f304e..44439a7 100644 --- a/Application/Migrator/MigrationDTOs.cs/nameentryfeedback.cs +++ b/Application/Migrator/MigrationDTOs.cs/nameentryfeedback.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Application.Migrator.MigrationDTOs.cs +namespace Application.Migrator.MigrationDTOs.cs { internal class nameentryfeedback { diff --git a/Application/Migrator/MigrationDTOs.cs/nameentryvideos.cs b/Application/Migrator/MigrationDTOs.cs/nameentryvideos.cs index 60c734d..849121a 100644 --- a/Application/Migrator/MigrationDTOs.cs/nameentryvideos.cs +++ b/Application/Migrator/MigrationDTOs.cs/nameentryvideos.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Application.Migrator.MigrationDTOs.cs +namespace Application.Migrator.MigrationDTOs.cs { internal class nameentryvideos { diff --git a/Application/Migrator/MigrationDTOs.cs/suggested_name.cs b/Application/Migrator/MigrationDTOs.cs/suggested_name.cs index d709667..84d6546 100644 --- a/Application/Migrator/MigrationDTOs.cs/suggested_name.cs +++ b/Application/Migrator/MigrationDTOs.cs/suggested_name.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Application.Migrator.MigrationDTOs.cs +namespace Application.Migrator.MigrationDTOs.cs { internal class suggested_name { diff --git a/Application/Migrator/MigrationDTOs.cs/users.cs b/Application/Migrator/MigrationDTOs.cs/users.cs index 6d9e5d0..a30c561 100644 --- a/Application/Migrator/MigrationDTOs.cs/users.cs +++ b/Application/Migrator/MigrationDTOs.cs/users.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Application.Migrator.MigrationDTOs.cs +namespace Application.Migrator.MigrationDTOs.cs { internal class users { diff --git a/Application/Migrator/SQLToMongoMigrator.cs b/Application/Migrator/SQLToMongoMigrator.cs index 7efbcf5..ca19c82 100644 --- a/Application/Migrator/SQLToMongoMigrator.cs +++ b/Application/Migrator/SQLToMongoMigrator.cs @@ -1,14 +1,15 @@ using Application.Migrator.MigrationDTOs.cs; -using Core.Dto; +using Core.Dto.Request; using Core.Entities; -using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; -using Core.Enums; using Dapper; using Microsoft.Extensions.Configuration; using MongoDB.Bson; using MongoDB.Driver; using MySqlConnector; +using YorubaOrganization.Core.Dto; +using YorubaOrganization.Core.Entities; +using YorubaOrganization.Core.Entities.Partials; +using YorubaOrganization.Core.Enums; namespace Application.Migrator { @@ -97,26 +98,34 @@ on a.place equals b.geo_location_place Select(s => new EmbeddedVideo(s.url, s.caption)).ToList(); } - List documentsToInsert = name_entry.Select(s => new NameEntry() - { - Id = ObjectId.GenerateNewId().ToString(), - Name = s.name, - ExtendedMeaning = s.extended_meaning, - FamousPeople = new CommaSeparatedString(s.famous_people), - IpaNotation = s.ipa_notation, - Meaning = s.meaning, - Media = new CommaSeparatedString(s.media), - Morphology = new CommaSeparatedString(s.morphology), - Pronunciation = s.pronunciation, - CreatedBy = s.submitted_by, - Syllables = new HyphenSeparatedString(s.syllables), - Variants = new CommaSeparatedString(s.variants), - State = GetPublishState(s.state), - Etymology = s.etymology, - GeoLocation = s.geoLocations, - Feedbacks = s.feedbacks, - Videos = s.embeddedVideo - }).ToList(); + List documentsToInsert = name_entry.Select(s => { + var updateNameDto = new UpdateNameDto + { + Media = new CommaSeparatedString(s.media), + Variants = new CommaSeparatedString(s.variants) + }; + + return new NameEntry() + { + Id = ObjectId.GenerateNewId().ToString(), + Title = s.name, + ExtendedMeaning = s.extended_meaning, + FamousPeople = new CommaSeparatedString(s.famous_people), + IpaNotation = s.ipa_notation, + Meaning = s.meaning, + MediaLinks = updateNameDto.MediaLinks, + Morphology = new CommaSeparatedString(s.morphology), + Pronunciation = s.pronunciation, + CreatedBy = s.submitted_by, + Syllables = new HyphenSeparatedString(s.syllables), + VariantsV2 = updateNameDto.VariantsV2, + State = GetPublishState(s.state), + Etymology = s.etymology, + GeoLocation = s.geoLocations, + Feedbacks = s.feedbacks, + Videos = s.embeddedVideo + }; + }).ToList(); _nameEntryCollection.DeleteMany(FilterDefinition.Empty); _nameEntryCollection.InsertMany(documentsToInsert); diff --git a/Application/Services/BasicAuthHandler.cs b/Application/Services/BasicAuthHandler.cs index f54c1fc..42b785a 100644 --- a/Application/Services/BasicAuthHandler.cs +++ b/Application/Services/BasicAuthHandler.cs @@ -1,6 +1,4 @@ -using Core.Entities; -using Core.Repositories; -using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Data; @@ -8,6 +6,8 @@ using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; +using YorubaOrganization.Core.Entities; +using YorubaOrganization.Core.Repositories; namespace Application.Services { diff --git a/Application/Services/EventPubService.cs b/Application/Services/EventPubService.cs index f97828c..dc2a153 100644 --- a/Application/Services/EventPubService.cs +++ b/Application/Services/EventPubService.cs @@ -1,6 +1,6 @@ -using Core.Events; +using Ardalis.GuardClauses; using MediatR; -using System.Reflection; +using YorubaOrganization.Core.Events; namespace Application.Services { @@ -15,16 +15,8 @@ public EventPubService(IMediator mediator) public async Task PublishEvent(T theEvent) { - var adapterClassName = typeof(T).Name + "Adapter"; - Type? adapterType = Assembly.GetExecutingAssembly().GetTypes().FirstOrDefault(t => t.Name == adapterClassName); - - if (adapterType == null) - { - throw new InvalidOperationException("Adapter type not found for " + typeof(T).FullName); - } - - var adapterEvent = Activator.CreateInstance(adapterType, theEvent)!; - await _mediator.Publish(adapterEvent); + Guard.Against.Null(theEvent, nameof(theEvent)); + await _mediator.Publish(theEvent); } } } diff --git a/Application/Services/GeoLocationsService.cs b/Application/Services/GeoLocationsService.cs index e09a01f..488d039 100644 --- a/Application/Services/GeoLocationsService.cs +++ b/Application/Services/GeoLocationsService.cs @@ -1,6 +1,6 @@ -using Application.Exceptions; -using Core.Entities; -using Core.Repositories; +using YorubaOrganization.Application.Exceptions; +using YorubaOrganization.Core.Entities; +using YorubaOrganization.Core.Repositories; namespace Application.Services { diff --git a/Application/Services/NameEntryFeedbackService.cs b/Application/Services/NameEntryFeedbackService.cs index 2f5625f..c55d8f3 100644 --- a/Application/Services/NameEntryFeedbackService.cs +++ b/Application/Services/NameEntryFeedbackService.cs @@ -12,12 +12,12 @@ public NameEntryFeedbackService(INameEntryFeedbackRepository nameEntryFeedbackRe _nameEntryFeedbackRepository = nameEntryFeedbackRepository; } - public async Task> FindAllAsync() + public async Task> FindAllAsync() { return await _nameEntryFeedbackRepository.FindAllAsync(); } - public async Task> FindByNameAsync(string name) + public async Task> FindByNameAsync(string name) { return await _nameEntryFeedbackRepository.FindByNameAsync(name); } @@ -32,7 +32,7 @@ public async Task DeleteAllFeedbackForNameAsync(string name) await _nameEntryFeedbackRepository.DeleteAllFeedbackForNameAsync(name); } - public async Task GetFeedbackByIdAsync(string feedbackId) + public async Task GetFeedbackByIdAsync(string feedbackId) { return await _nameEntryFeedbackRepository.GetFeedbackByIdAsync(feedbackId); } diff --git a/Application/Services/NameEntryService.cs b/Application/Services/NameEntryService.cs index a014b2f..79cd358 100644 --- a/Application/Services/NameEntryService.cs +++ b/Application/Services/NameEntryService.cs @@ -1,14 +1,13 @@ -using Application.Exceptions; -using Core.Dto.Response; -using Core.Entities.NameEntry; -using Core.Enums; +using Core.Entities; using Core.Events; using Core.Repositories; using Microsoft.Extensions.Logging; +using YorubaOrganization.Application.Services; +using YorubaOrganization.Core.Events; -namespace Application.Domain +namespace Application.Services { - public class NameEntryService + public class NameEntryService : DictionaryEntryService { private readonly INameEntryRepository _nameEntryRepository; private readonly IEventPubService _eventPubService; @@ -18,128 +17,13 @@ public NameEntryService( INameEntryRepository nameEntryRepository, IEventPubService eventPubService, ILogger logger - ) + ) : base(nameEntryRepository, eventPubService, logger) { _nameEntryRepository = nameEntryRepository; _eventPubService = eventPubService; _logger = logger; } - public async Task Create(NameEntry entry) - { - var name = entry.Name; - - var existingName = await _nameEntryRepository.FindByName(name); - if (existingName != null) - { - existingName.Duplicates.Add(entry); - await UpdateName(existingName); - _logger.LogWarning("Someone attempted to create a new name over existing name: {name}.", name); - return; - } - - await CreateOrUpdateName(entry); - } - - public async Task BulkCreate(List entries) - { - foreach (var entry in entries) - { - await Create(entry); - // TODO Later: Ensure that removing batched writes to database here will not cause problems - } - } - - public async Task CreateOrUpdateName(NameEntry entry) - { - var updated = await UpdateNameWithUnpublish(entry); - - if (updated == null) - { - await _nameEntryRepository.Create(entry); - } - - return updated ?? entry; - } - - public async Task> SaveNames(List entries) - { - var savedNames = new List(); - foreach (var entry in entries) - { - savedNames.Add(await CreateOrUpdateName(entry)); - // TODO Later: Ensure that removing batched writes to database here will not cause problems - } - return savedNames; - } - - public async Task UpdateName(NameEntry nameEntry) - { - return await _nameEntryRepository.Update(nameEntry.Name, nameEntry); - } - - public async Task PublishName(NameEntry nameEntry, string username) - { - if (string.IsNullOrEmpty(username)) - { - throw new ClientException("Unexpected. User must be logged in to publish a name!"); - } - - NameEntry? updates = nameEntry.Modified; - string originalName = nameEntry.Name; - - if (updates != null) - { - // Copy latest updates to the main object as part of the publish operation. - nameEntry.Name = updates.Name; - nameEntry.Pronunciation = updates.Pronunciation; - nameEntry.IpaNotation = updates.IpaNotation; - nameEntry.Meaning = updates.Meaning; - nameEntry.ExtendedMeaning = updates.ExtendedMeaning; - nameEntry.Morphology = updates.Morphology; - nameEntry.Media = updates.Media; - nameEntry.State = updates.State; - nameEntry.Etymology = updates.Etymology; - nameEntry.Videos = updates.Videos; - nameEntry.GeoLocation = updates.GeoLocation; - nameEntry.FamousPeople = updates.FamousPeople; - nameEntry.Syllables = updates.Syllables; - nameEntry.Variants = updates.Variants; - nameEntry.UpdatedBy = username; - - nameEntry.Modified = null; - } - - nameEntry.State = State.PUBLISHED; - await _nameEntryRepository.Update(originalName, nameEntry); - - // TODO Later: Use the outbox pattern to enforce event publishing after the DB update (https://www.youtube.com/watch?v=032SfEBFIJs&t=913s). - await _eventPubService.PublishEvent(new NameIndexed(nameEntry.Name, nameEntry.Meaning)); - } - - public async Task UpdateNameWithUnpublish(NameEntry nameEntry) - { - if (nameEntry.State == State.PUBLISHED) - { - // Unpublish name if it is currently published since it is awaiting some changes. - nameEntry.State = State.MODIFIED; - } - - return await UpdateName(nameEntry); - } - - /// - /// Update an existing NameEntry with a new version. - /// - /// - /// - /// - public async Task UpdateName(NameEntry originalEntry, NameEntry newEntry) - { - originalEntry.Modified = newEntry; - return await UpdateNameWithUnpublish(originalEntry); - } - public async Task> BulkUpdateNames(List nameEntries) { var updatedNames = new List(); @@ -147,7 +31,7 @@ public async Task> BulkUpdateNames(List nameEntries) // TODO Later: Update all names in one batch foreach (var nameEntry in nameEntries) { - var updated = await UpdateNameWithUnpublish(nameEntry); + var updated = await UpdateEntryWithUnpublish(nameEntry); if (updated != null) { @@ -155,65 +39,17 @@ public async Task> BulkUpdateNames(List nameEntries) } else { - await _eventPubService.PublishEvent(new NonExistingNameUpdateAttempted(nameEntry.Name)); + await _eventPubService.PublishEvent(new NonExistingEntryUpdateAttempted(nameEntry.Title)); } } return updatedNames; } - public async Task> ListNames() + public async override Task PublishEntry(NameEntry nameEntry, string username) { - var result = await _nameEntryRepository.ListAll(); - return result.ToList(); - } - - public async Task GetMetadata() - { - return await _nameEntryRepository.GetMetadata(); - } - - public async Task> FindBy(State state) - { - return await _nameEntryRepository.FindByState(state); - } - - public async Task> GetAllNames(State? state, string? submittedBy) - { - return await _nameEntryRepository.GetAllNames(state, submittedBy); - } - - public async Task> List(State? state, string? submittedBy, int pageNumber, int pageSize) - { - return await _nameEntryRepository.List(pageNumber, pageSize, state, submittedBy); - } - - public async Task LoadName(string name) - { - return await _nameEntryRepository.FindByName(name); - } - - public async Task> LoadNames(string[] names) - { - return await _nameEntryRepository.FindByNames(names); - } - - public async Task Delete(string name) - { - await _nameEntryRepository.Delete(name); - await PublishNameDeletedEvent(name); - } - - private async Task PublishNameDeletedEvent(string name) - { - await _eventPubService.PublishEvent(new NameDeleted(name)); - } - - public async Task FindByNameAndState(string name, State state) => - await _nameEntryRepository.FindByNameAndState(name, state); - - public async Task DeleteNamesBatch(string[] names) - { - await _nameEntryRepository.DeleteMany(names); + await base.PublishEntry(nameEntry, username, n => n.Meaning, n => n.ExtendedMeaning, n => n.FamousPeople); + // TODO Later: Use the outbox pattern to enforce event publishing after the DB update (https://www.youtube.com/watch?v=032SfEBFIJs&t=913s). + await _eventPubService.PublishEvent(new NameIndexed(nameEntry.Title, nameEntry.Meaning)); } } } \ No newline at end of file diff --git a/Application/Services/SearchService.cs b/Application/Services/SearchService.cs index 4c38f93..600f94d 100644 --- a/Application/Services/SearchService.cs +++ b/Application/Services/SearchService.cs @@ -1,8 +1,9 @@ using Application.Mappers; using Core.Dto.Response; -using Core.Entities.NameEntry; -using Core.Enums; +using Core.Entities; using Core.Repositories; +using YorubaOrganization.Core.Dto.Response; +using YorubaOrganization.Core.Enums; namespace Application.Services { @@ -21,18 +22,18 @@ public async Task> AutoComplete(string query) if(query.Length > 1) { - namesResult = await _namesRepository.FindByNameStartingWithAndState(query, State.PUBLISHED); + namesResult = await _namesRepository.FindByTitleStartingWithAndState(query, State.PUBLISHED); } - var namesContainingQuery = await _namesRepository.FindNameEntryByNameContainingAndState(query, State.PUBLISHED); + var namesContainingQuery = await _namesRepository.FindEntryByTitleContainingAndState(query, State.PUBLISHED); namesResult.UnionWith(namesContainingQuery); - return new HashSet(namesResult.Select(n => n.Name)); + return new HashSet(namesResult.Select(n => n.Title)); } public async Task GetName(string searchTerm) { - var result = await _namesRepository.FindByNameAndState(searchTerm, State.PUBLISHED); + var result = await _namesRepository.FindByTitleAndState(searchTerm, State.PUBLISHED); return result?.MapToDto(); } @@ -48,23 +49,23 @@ public async Task GetNamesMetadata() public async Task> Search(string searchTerm) { - var exactFound = await _namesRepository.FindByNameAndState(searchTerm, State.PUBLISHED); + var exactFound = await _namesRepository.FindByTitleAndState(searchTerm, State.PUBLISHED); if (exactFound != null) { return new NameEntry[] { exactFound }; } - var startingWithSearchTerm = await _namesRepository.FindByNameStartingWithAndState(searchTerm, State.PUBLISHED); + var startingWithSearchTerm = await _namesRepository.FindByTitleStartingWithAndState(searchTerm, State.PUBLISHED); if (startingWithSearchTerm.Any()) { return startingWithSearchTerm; } var possibleFound = new HashSet(); - possibleFound.UnionWith(await _namesRepository.FindNameEntryByNameContainingAndState(searchTerm, State.PUBLISHED)); - possibleFound.UnionWith(await _namesRepository.FindNameEntryByVariantsContainingAndState(searchTerm, State.PUBLISHED)); - possibleFound.UnionWith(await _namesRepository.FindNameEntryByMeaningContainingAndState(searchTerm, State.PUBLISHED)); - possibleFound.UnionWith(await _namesRepository.FindNameEntryByExtendedMeaningContainingAndState(searchTerm, State.PUBLISHED)); + possibleFound.UnionWith(await _namesRepository.FindEntryByTitleContainingAndState(searchTerm, State.PUBLISHED)); + possibleFound.UnionWith(await _namesRepository.FindEntryByVariantsContainingAndState(searchTerm, State.PUBLISHED)); + possibleFound.UnionWith(await _namesRepository.FindEntryByMeaningContainingAndState(searchTerm, State.PUBLISHED)); + possibleFound.UnionWith(await _namesRepository.FindEntryByExtendedMeaningContainingAndState(searchTerm, State.PUBLISHED)); return possibleFound; @@ -72,7 +73,7 @@ public async Task> Search(string searchTerm) public async Task> SearchByStartsWith(string searchTerm) { - return await _namesRepository.FindByNameStartingWithAndState(searchTerm, State.PUBLISHED); + return await _namesRepository.FindByTitleStartingWithAndState(searchTerm, State.PUBLISHED); } } } diff --git a/Application/Services/UserService.cs b/Application/Services/UserService.cs index 3cad646..b6e6529 100644 --- a/Application/Services/UserService.cs +++ b/Application/Services/UserService.cs @@ -1,7 +1,7 @@ -using Core.Dto.Request; -using Core.Dto.Response; -using Core.Entities; -using Core.Repositories; +using YorubaOrganization.Core.Dto.Request; +using YorubaOrganization.Core.Dto.Response; +using YorubaOrganization.Core.Entities; +using YorubaOrganization.Core.Repositories; using BCrypt_ = BCrypt.Net.BCrypt; namespace Application.Services diff --git a/Application/Validation/CreateUserValidator.cs b/Application/Validation/CreateUserValidator.cs index e5df7ee..6c307cf 100644 --- a/Application/Validation/CreateUserValidator.cs +++ b/Application/Validation/CreateUserValidator.cs @@ -1,6 +1,6 @@ -using Core.Dto.Response; -using Core.Enums; -using FluentValidation; +using FluentValidation; +using YorubaOrganization.Core.Dto.Request; +using YorubaOrganization.Core.Enums; namespace Application.Validation { diff --git a/Application/Validation/EmbeddedVideoValidator.cs b/Application/Validation/EmbeddedVideoValidator.cs index aa2ca23..2fd3972 100644 --- a/Application/Validation/EmbeddedVideoValidator.cs +++ b/Application/Validation/EmbeddedVideoValidator.cs @@ -1,10 +1,5 @@ -using Core.Dto.Request; -using FluentValidation; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using FluentValidation; +using YorubaOrganization.Core.Dto.Request; namespace Application.Validation { diff --git a/Application/Validation/EtymologyValidator.cs b/Application/Validation/EtymologyValidator.cs index 02371e0..5f42349 100644 --- a/Application/Validation/EtymologyValidator.cs +++ b/Application/Validation/EtymologyValidator.cs @@ -1,10 +1,5 @@ -using Core.Dto.Request; -using FluentValidation; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using FluentValidation; +using YorubaOrganization.Core.Dto.Request; namespace Application.Validation { diff --git a/Application/Validation/GeoLocationValidator.cs b/Application/Validation/GeoLocationValidator.cs index 233e46e..19aa950 100644 --- a/Application/Validation/GeoLocationValidator.cs +++ b/Application/Validation/GeoLocationValidator.cs @@ -1,6 +1,6 @@ -using Core.Dto.Request; -using Core.Repositories; -using FluentValidation; +using FluentValidation; +using YorubaOrganization.Core.Dto.Request; +using YorubaOrganization.Core.Repositories; namespace Application.Validation { diff --git a/Application/Validation/UpdateUserValidator.cs b/Application/Validation/UpdateUserValidator.cs index a6e3648..d2599fe 100644 --- a/Application/Validation/UpdateUserValidator.cs +++ b/Application/Validation/UpdateUserValidator.cs @@ -1,6 +1,6 @@ -using Core.Dto.Request; -using Core.Enums; -using FluentValidation; +using FluentValidation; +using YorubaOrganization.Core.Dto.Request; +using YorubaOrganization.Core.Enums; namespace Application.Validation { diff --git a/Core/Cache/IRecentIndexesCache.cs b/Core/Cache/IRecentIndexesCache.cs deleted file mode 100644 index f533cd7..0000000 --- a/Core/Cache/IRecentIndexesCache.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Core.Cache -{ - public interface IRecentIndexesCache : ISetBasedCache - { - } -} \ No newline at end of file diff --git a/Core/Cache/IRecentSearchesCache.cs b/Core/Cache/IRecentSearchesCache.cs deleted file mode 100644 index 73d9bd3..0000000 --- a/Core/Cache/IRecentSearchesCache.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Core.Cache -{ - public interface IRecentSearchesCache : ISetBasedCache - { - Task> GetMostPopular(); - } -} \ No newline at end of file diff --git a/Core/Cache/ISetBasedCache.cs b/Core/Cache/ISetBasedCache.cs deleted file mode 100644 index 581739e..0000000 --- a/Core/Cache/ISetBasedCache.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Core.Cache -{ - public interface ISetBasedCache - { - Task> Get(); - Task Stack(T item); - Task Remove(T item); - } -} diff --git a/Core/Cache/ISimpleCache.cs b/Core/Cache/ISimpleCache.cs deleted file mode 100644 index d943fb8..0000000 --- a/Core/Cache/ISimpleCache.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Core.Cache -{ - public interface ISimpleCache - { - Task SetAsync(string key, T value, TimeSpan? expiry = null); - Task GetAsync(string key); - } -} diff --git a/Core/Core.csproj b/Core/Core.csproj index a6b9113..f754c70 100644 --- a/Core/Core.csproj +++ b/Core/Core.csproj @@ -4,4 +4,7 @@ enable enable + + + \ No newline at end of file diff --git a/Core/Dto/CharacterSeparatedString.cs b/Core/Dto/CharacterSeparatedString.cs deleted file mode 100644 index 57eb98b..0000000 --- a/Core/Dto/CharacterSeparatedString.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Core.Dto -{ - public abstract class CharacterSeparatedString where T : CharacterSeparatedString - { - protected string SeparatorOut { get; init; } - protected char SeparatorIn { get; init; } - protected readonly string value; - - public CharacterSeparatedString(string? value) - { - this.value = value?.Trim() ?? string.Empty; - } - - public override string ToString() - { - return value; - } - - public static implicit operator CharacterSeparatedString(List list) - { - var anObject = (CharacterSeparatedString) Activator.CreateInstance(typeof(T), string.Empty); - return (CharacterSeparatedString) Activator.CreateInstance(typeof(T), string.Join(anObject.SeparatorOut, list)); - - } - - public static implicit operator List(CharacterSeparatedString charSeparatedString) - { - return charSeparatedString.value - .Split(charSeparatedString.SeparatorIn) - .Select(item => item.Trim()) - .Where(item => !string.IsNullOrEmpty(item)) - .ToList(); - } - } -} diff --git a/Core/Dto/CommaSeparatedString.cs b/Core/Dto/CommaSeparatedString.cs deleted file mode 100644 index a272f4e..0000000 --- a/Core/Dto/CommaSeparatedString.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Core.Dto -{ - public class CommaSeparatedString : CharacterSeparatedString - { - public CommaSeparatedString(string? value) : base(value) - { - SeparatorIn = ','; - SeparatorOut = ", "; - } - } -} diff --git a/Core/Dto/HyphenSeparatedString.cs b/Core/Dto/HyphenSeparatedString.cs deleted file mode 100644 index c3e5426..0000000 --- a/Core/Dto/HyphenSeparatedString.cs +++ /dev/null @@ -1,14 +0,0 @@ - -namespace Core.Dto -{ - public class HyphenSeparatedString : CharacterSeparatedString - { - public HyphenSeparatedString(string? value) : base(value) - { - SeparatorIn = '-'; - SeparatorOut = "-"; - } - - - } -} diff --git a/Core/Dto/Request/CreateGeoLocationDto.cs b/Core/Dto/Request/CreateGeoLocationDto.cs deleted file mode 100644 index e334b9c..0000000 --- a/Core/Dto/Request/CreateGeoLocationDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Core.Dto.Request -{ - public record CreateGeoLocationDto(string Place, string Region) - { - } -} diff --git a/Core/Dto/Request/CreateNameDto.cs b/Core/Dto/Request/CreateNameDto.cs index 59cefb8..6bfcecb 100644 --- a/Core/Dto/Request/CreateNameDto.cs +++ b/Core/Dto/Request/CreateNameDto.cs @@ -1,6 +1,4 @@ -using Core.Dto.Request; - -namespace Api.Model.In +namespace Core.Dto.Request { public class CreateNameDto : NameDto { diff --git a/Core/Dto/Request/CreateNameFeedbackDto.cs b/Core/Dto/Request/CreateNameFeedbackDto.cs index 9ee0f0f..d068866 100644 --- a/Core/Dto/Request/CreateNameFeedbackDto.cs +++ b/Core/Dto/Request/CreateNameFeedbackDto.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Api.Model.Request +namespace Core.Dto.Request { public record CreateNameFeedbackDto([Required] string Name, [Required(ErrorMessage = "Cannot give an empty feedback")] string Feedback); } diff --git a/Core/Dto/Request/CreateSuggestedNameDto.cs b/Core/Dto/Request/CreateSuggestedNameDto.cs index de0cb9c..3b72c55 100644 --- a/Core/Dto/Request/CreateSuggestedNameDto.cs +++ b/Core/Dto/Request/CreateSuggestedNameDto.cs @@ -1,4 +1,6 @@ -namespace Core.Dto.Request; +using YorubaOrganization.Core.Dto.Request; + +namespace Core.Dto.Request; public record CreateSuggestedNameDto { diff --git a/Core/Dto/Request/CreateUserDto.cs b/Core/Dto/Request/CreateUserDto.cs deleted file mode 100644 index 93afbab..0000000 --- a/Core/Dto/Request/CreateUserDto.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Core.Dto.Response -{ - public record CreateUserDto - { - public string? Username { get; set; } - public string? Password { get; set; } - public string? Email { get; set; } - - public string[] Roles { get; set; } - - public string? CreatedBy { get; set; } - } -} diff --git a/Core/Dto/Request/EmbeddedVideoDto.cs b/Core/Dto/Request/EmbeddedVideoDto.cs deleted file mode 100644 index 036fbbf..0000000 --- a/Core/Dto/Request/EmbeddedVideoDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Core.Dto.Request -{ - public record EmbeddedVideoDto(string VideoId, string Caption) - { - } -} - diff --git a/Core/Dto/Request/EtymologyDto.cs b/Core/Dto/Request/EtymologyDto.cs deleted file mode 100644 index cb1a815..0000000 --- a/Core/Dto/Request/EtymologyDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Core.Dto.Request -{ - /// - /// TODO: Add field validations - /// - public record EtymologyDto(string Part, string Meaning) - { - } - -} diff --git a/Core/Dto/Request/NameDto.cs b/Core/Dto/Request/NameDto.cs index beb674e..43d8698 100644 --- a/Core/Dto/Request/NameDto.cs +++ b/Core/Dto/Request/NameDto.cs @@ -1,4 +1,7 @@ -using Core.Enums; +using YorubaOrganization.Core.Dto; +using YorubaOrganization.Core.Dto.Request; +using YorubaOrganization.Core.Entities.Partials; +using YorubaOrganization.Core.Enums; namespace Core.Dto.Request { @@ -20,6 +23,21 @@ public abstract class NameDto public CommaSeparatedString? Variants { get; set; } + public List VariantsV2 { + get + { + return Variants == null ? [] : ((List)Variants).Select(v => new Variant(v)).ToList(); + } + } + + public List MediaLinks + { + get + { + return Media == null ? [] : ((List)Media).Select(m => new MediaLink(m)).ToList(); + } + } + public CommaSeparatedString? Morphology { get; set; } public CommaSeparatedString? Media { get; set; } diff --git a/Core/Dto/Request/UpdateUserDto.cs b/Core/Dto/Request/UpdateUserDto.cs deleted file mode 100644 index ee762b5..0000000 --- a/Core/Dto/Request/UpdateUserDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Core.Dto.Request -{ - public record UpdateUserDto (string? Username, string? Password, string[]? Roles, string? UpdatedBy) - { - - } -} diff --git a/Core/Dto/Response/FeedbackDto.cs b/Core/Dto/Response/FeedbackDto.cs deleted file mode 100644 index 5aae22a..0000000 --- a/Core/Dto/Response/FeedbackDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Core.Dto.Response -{ - public record FeedbackDto(string Id, string Name, string Feedback, DateTime SubmittedAt) - { - } -} diff --git a/Core/Dto/Response/GeoLocationDto.cs b/Core/Dto/Response/GeoLocationDto.cs deleted file mode 100644 index 99441ae..0000000 --- a/Core/Dto/Response/GeoLocationDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Core.Dto.Response -{ - public record GeoLocationDto(string Id, string Place, string Region) - { - public override string ToString() - { - return Place; - } - } -} diff --git a/Core/Dto/Response/NameEntryDto.cs b/Core/Dto/Response/NameEntryDto.cs index 44325d6..b0000e2 100644 --- a/Core/Dto/Response/NameEntryDto.cs +++ b/Core/Dto/Response/NameEntryDto.cs @@ -1,5 +1,7 @@ -using Core.Dto.Request; -using Core.Enums; +using YorubaOrganization.Core.Dto; +using YorubaOrganization.Core.Dto.Request; +using YorubaOrganization.Core.Dto.Response; +using YorubaOrganization.Core.Enums; namespace Core.Dto.Response { diff --git a/Core/Dto/Response/NameFeedbackDto.cs b/Core/Dto/Response/NameFeedbackDto.cs new file mode 100644 index 0000000..7e2f0db --- /dev/null +++ b/Core/Dto/Response/NameFeedbackDto.cs @@ -0,0 +1,6 @@ +namespace Core.Dto.Response +{ + public record NameFeedbackDto(string Id, string Name, string Feedback, DateTime SubmittedAt) + { + } +} diff --git a/Core/Dto/Response/RecentStats.cs b/Core/Dto/Response/RecentStats.cs deleted file mode 100644 index 3904200..0000000 --- a/Core/Dto/Response/RecentStats.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Core.Dto.Response -{ - public record RecentStats - { - [JsonPropertyName("search")] - public string[] LatestSearches { get; init; } - - [JsonPropertyName("index")] - public string[] LatestAdditions { get; init; } - - [JsonPropertyName("popular")] - public string[] MostPopular { get; init; } - - public RecentStats() - { - LatestSearches = new string[0]; - LatestAdditions = new string[0]; - MostPopular = new string[0]; - } - } -} diff --git a/Core/Dto/Response/SearchMetadataDto.cs b/Core/Dto/Response/SearchMetadataDto.cs deleted file mode 100644 index 7bb5a51..0000000 --- a/Core/Dto/Response/SearchMetadataDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Core.Dto.Response -{ - public class SearchMetadataDto - { - public int TotalPublishedNames { get; set; } - } -} diff --git a/Core/Dto/Response/SuggestedNameDto.cs b/Core/Dto/Response/SuggestedNameDto.cs index c1d1cc8..17d356b 100644 --- a/Core/Dto/Response/SuggestedNameDto.cs +++ b/Core/Dto/Response/SuggestedNameDto.cs @@ -1,4 +1,6 @@ -namespace Core.Dto.Response; +using YorubaOrganization.Core.Dto.Response; + +namespace Core.Dto.Response; public record SuggestedNameDto { diff --git a/Core/Dto/Response/UserDto.cs b/Core/Dto/Response/UserDto.cs deleted file mode 100644 index 6a9c157..0000000 --- a/Core/Dto/Response/UserDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Core.Dto.Response -{ - public record UserDto - { - public string Email { get; set; } - public string? Username { get; set; } - public string[] Roles { get; set; } - } -} diff --git a/Core/Entities/BaseEntity.cs b/Core/Entities/BaseEntity.cs deleted file mode 100644 index 1fd9f47..0000000 --- a/Core/Entities/BaseEntity.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Core.Entities -{ - public abstract class BaseEntity - { - public BaseEntity() - { - CreatedAt = DateTime.UtcNow; - UpdatedAt = CreatedAt; - } - - public string Id { get; set; } - public DateTime CreatedAt { get; set; } - - public string? CreatedBy { get; set; } - public DateTime UpdatedAt { get; set; } - public string? UpdatedBy { get; set; } - } -} diff --git a/Core/Entities/GeoLocation.cs b/Core/Entities/GeoLocation.cs deleted file mode 100644 index f0465f4..0000000 --- a/Core/Entities/GeoLocation.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Core.Entities -{ - public class GeoLocation : BaseEntity - { - public string Place { get; set; } - - // TODO Minor: Get Kola's brief about why this is separated into Place and Region. - // TODO Minor: Is the Region which is not currently used anywhere of any use? - public string Region { get; set; } - - - public GeoLocation() { } - - public GeoLocation(string place, string region) - { - Place = place; - Region = region; - } - } -} \ No newline at end of file diff --git a/Core/Entities/NameEntry.cs b/Core/Entities/NameEntry.cs new file mode 100644 index 0000000..7438491 --- /dev/null +++ b/Core/Entities/NameEntry.cs @@ -0,0 +1,16 @@ +using YorubaOrganization.Core.Entities; + +namespace Core.Entities; + +public class NameEntry : DictionaryEntry +{ + public string Meaning { get; set; } + public string? ExtendedMeaning { get; set; } + public List FamousPeople { get; set; } + + protected override void InitializeLists() + { + base.InitializeLists(); + FamousPeople = []; + } +} \ No newline at end of file diff --git a/Core/Entities/NameEntry/Collections/EmbeddedVideo.cs b/Core/Entities/NameEntry/Collections/EmbeddedVideo.cs deleted file mode 100644 index 98e1183..0000000 --- a/Core/Entities/NameEntry/Collections/EmbeddedVideo.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Core.Entities.NameEntry.Collections; - -public class EmbeddedVideo : BaseEntity -{ - public EmbeddedVideo(string videoId, string caption) - { - VideoId = videoId; - Caption = caption; - } - - public string VideoId { get; set; } - public string Caption { get; set; } - -} diff --git a/Core/Entities/NameEntry/Collections/Etymology.cs b/Core/Entities/NameEntry/Collections/Etymology.cs deleted file mode 100644 index 48e01e8..0000000 --- a/Core/Entities/NameEntry/Collections/Etymology.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Core.Entities.NameEntry.Collections; - -public class Etymology : BaseEntity -{ - public string Part { get; set; } - public string Meaning { get; set; } - - public Etymology(string part, string meaning) - { - Part = part; - Meaning = meaning; - } -} \ No newline at end of file diff --git a/Core/Entities/NameEntry/Collections/Feedback.cs b/Core/Entities/NameEntry/Collections/Feedback.cs deleted file mode 100644 index 65232bf..0000000 --- a/Core/Entities/NameEntry/Collections/Feedback.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Core.Entities.NameEntry.Collections -{ - /// - /// Feedback from anonymous website users. - /// TODO Later: We can capture their name or email address going forward (in the universal SubmittedBy field) - /// - public class Feedback : BaseEntity - { - public string? Content { get; set; } - - public Feedback() { } - - public Feedback(string content) - { - Content = content; - } - } -} diff --git a/Core/Entities/NameEntry/NameEntry.cs b/Core/Entities/NameEntry/NameEntry.cs deleted file mode 100644 index a4f5142..0000000 --- a/Core/Entities/NameEntry/NameEntry.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Core.Entities.NameEntry.Collections; -using Core.Enums; - -namespace Core.Entities.NameEntry; - -public class NameEntry : BaseEntity, IComparable -{ - public string Name { get; set; } - - public string? Pronunciation { get; set; } - // Only 3 values exist in the DB for this field at the moment. However, Kola would like to retain it. - public string? IpaNotation { get; set; } - public string Meaning { get; set; } - public string? ExtendedMeaning { get; set; } - public List Morphology { get; set; } - public List Media { get; set; } - public State State { get; set; } - - // Note: Did not migrate TonalMark, Tags, InOtherLanguages intentionally since all values are null in the database (no admin boxes for them) - public List Etymology { get; set; } - public List Videos { get; set; } - public List GeoLocation { get; set; } - - public List FamousPeople { get; set; } - - public List Syllables { get; set; } - - public List Variants { get; set; } - - /// - /// When the current entry is edited, the edited version will be stored here until it is published - /// - public NameEntry? Modified { get; set; } - - /// - /// An item is added in here if an attempt is made to create a name which already exists. - /// Only adding in this feature because it exists in the Java version. - /// There is no functional application of these objects at the moment. - /// - public List Duplicates { get; set; } - public List Feedbacks { get; set; } - - - private void InitializeLists() - { - Etymology = new List(); - Videos = new List(); - Duplicates = new List(); - Feedbacks = new List(); - - Syllables = new List(); - FamousPeople = new List(); - Variants = new List(); - Morphology = new List(); - Media = new List(); - - GeoLocation = new List(); - } - - public NameEntry(string name, string meaning) - { - Name = name ?? throw new ArgumentNullException(nameof(name)); - Meaning = meaning ?? throw new ArgumentNullException(nameof(meaning)); - - InitializeLists(); - } - public NameEntry() - { - InitializeLists(); - } - - public int CompareTo(NameEntry? other) - { - return Name.CompareTo(other?.Name); - } - - public override int GetHashCode() - { - return HashCode.Combine(Name); - } - - public override bool Equals(object? obj) - { - // Standard equality checks - if (obj is not NameEntry) - { - return false; - } - - NameEntry other = (NameEntry)obj; - - return Name == other.Name; - } -} \ No newline at end of file diff --git a/Core/Entities/SuggestedName.cs b/Core/Entities/SuggestedName.cs index 69347d7..cf5d6ed 100644 --- a/Core/Entities/SuggestedName.cs +++ b/Core/Entities/SuggestedName.cs @@ -1,4 +1,6 @@ -namespace Core.Entities +using YorubaOrganization.Core.Entities; + +namespace Core.Entities { public class SuggestedName : BaseEntity { diff --git a/Core/Entities/User.cs b/Core/Entities/User.cs deleted file mode 100644 index ea41ead..0000000 --- a/Core/Entities/User.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Core.Entities -{ - public class User : BaseEntity - { - public string? Email { get; set; } - public string? Username { get; set; } - public string? Password { get; set; } - public List Roles { get; set; } - - public User() - { - Roles = new List(); - } - } -} diff --git a/Core/Enums/Role.cs b/Core/Enums/Role.cs deleted file mode 100644 index b39a49b..0000000 --- a/Core/Enums/Role.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Core.Enums -{ - public enum Role - { - ADMIN, - PRO_LEXICOGRAPHER, - BASIC_LEXICOGRAPHER - } -} diff --git a/Core/Enums/State.cs b/Core/Enums/State.cs deleted file mode 100644 index 580c08e..0000000 --- a/Core/Enums/State.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Core.Enums; - -public enum State -{ - NEW, - UNPUBLISHED, - PUBLISHED, - MODIFIED -} \ No newline at end of file diff --git a/Core/Events/ExactNameSearched.cs b/Core/Events/ExactNameSearched.cs deleted file mode 100644 index 0de0ffb..0000000 --- a/Core/Events/ExactNameSearched.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Core.Events -{ - public record ExactNameSearched(string SearchTerm) - { - - } -} \ No newline at end of file diff --git a/Core/Events/IEventPubService.cs b/Core/Events/IEventPubService.cs deleted file mode 100644 index c323f80..0000000 --- a/Core/Events/IEventPubService.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Core.Events -{ - public interface IEventPubService - { - Task PublishEvent(T theEvent); - } -} diff --git a/Core/Events/NameDeleted.cs b/Core/Events/NameDeleted.cs deleted file mode 100644 index 4816490..0000000 --- a/Core/Events/NameDeleted.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Core.Events; - -public record class NameDeleted(string Name) -{ -} \ No newline at end of file diff --git a/Core/Events/NameEntryNameUpdated.cs b/Core/Events/NameEntryNameUpdated.cs deleted file mode 100644 index 0588536..0000000 --- a/Core/Events/NameEntryNameUpdated.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Core.Events -{ - public record NameEntryNameUpdated(string OriginalName, string NewName) - { - } -} \ No newline at end of file diff --git a/Core/Events/NameIndexed.cs b/Core/Events/NameIndexed.cs index 52f6a65..30cd03c 100644 --- a/Core/Events/NameIndexed.cs +++ b/Core/Events/NameIndexed.cs @@ -1,6 +1,8 @@ -namespace Core.Events +using MediatR; + +namespace Core.Events { - public record NameIndexed(string Name, string Meaning) + public record NameIndexed(string Name, string Meaning) : INotification { } } diff --git a/Core/Events/NonExistingNameUpdateAttempted.cs b/Core/Events/NonExistingNameUpdateAttempted.cs deleted file mode 100644 index a9ae1df..0000000 --- a/Core/Events/NonExistingNameUpdateAttempted.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Core.Events -{ - public record NonExistingNameUpdateAttempted(string Name) - { - } -} \ No newline at end of file diff --git a/Core/Repositories/IEtymologyRepository.cs b/Core/Repositories/IEtymologyRepository.cs deleted file mode 100644 index 16fd445..0000000 --- a/Core/Repositories/IEtymologyRepository.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Core.Repositories -{ - public interface IEtymologyRepository - { - Task> GetLatestMeaningOf(IEnumerable parts); - } -} diff --git a/Core/Repositories/IGeoLocationsRepository.cs b/Core/Repositories/IGeoLocationsRepository.cs deleted file mode 100644 index 3f826ec..0000000 --- a/Core/Repositories/IGeoLocationsRepository.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Core.Entities; - -namespace Core.Repositories -{ - public interface IGeoLocationsRepository - { - Task> GetAll(); - - Task FindByPlace(string place); - Task FindByPlaceAndRegion(string region, string place); - Task Create(GeoLocation geoLocation); - Task Delete(string id, string place); - } -} diff --git a/Core/Repositories/INameEntryFeedbackRepository.cs b/Core/Repositories/INameEntryFeedbackRepository.cs index f2f9876..b66224f 100644 --- a/Core/Repositories/INameEntryFeedbackRepository.cs +++ b/Core/Repositories/INameEntryFeedbackRepository.cs @@ -1,13 +1,14 @@ using Core.Dto.Response; + namespace Core.Repositories { public interface INameEntryFeedbackRepository { - Task> FindAllAsync(); - Task> FindByNameAsync(string name); + Task> FindAllAsync(); + Task> FindByNameAsync(string name); Task AddFeedbackByNameAsync(string name, string feedbackContent); Task DeleteAllFeedbackForNameAsync(string name); - Task GetFeedbackByIdAsync(string feedbackId); + Task GetFeedbackByIdAsync(string feedbackId); Task DeleteFeedbackAsync(string name, string feedbackId); } } diff --git a/Core/Repositories/INameEntryRepository.cs b/Core/Repositories/INameEntryRepository.cs index 29b9346..fbec837 100644 --- a/Core/Repositories/INameEntryRepository.cs +++ b/Core/Repositories/INameEntryRepository.cs @@ -1,54 +1,11 @@ -using Core.Dto.Response; -using Core.Entities.NameEntry; -using Core.Enums; -using System.Linq.Expressions; +using Core.Entities; +using YorubaOrganization.Core.Enums; +using YorubaOrganization.Core.Repositories; namespace Core.Repositories { - public interface INameEntryRepository + public interface INameEntryRepository : IDictionaryEntryRepository { - Task FindById(string id); - Task Create(NameEntry newName); - Task Create(List newName); - Task DeleteAll(); - - // TODO Later: This method should not be accessible. Too heavy on the DB - Task> ListAll(); - - Task FindByName(string name); - Task> FindByNames(string[] names); - - Task> FindByState(State state); - - Task> FindByNameStartingWithAndState(string alphabet, State state); - - Task> FindByNameStartingWithAnyAndState(IEnumerable searchTerms, State state); - - Task> FindNameEntryByNameContainingAndState(string name, State state); - - Task> FindNameEntryByVariantsContainingAndState(string name, State state); - - Task> FindNameEntryByMeaningContainingAndState(string name, State state); - - Task> FindNameEntryByExtendedMeaningContainingAndState(string name, State state); - - Task FindByNameAndState(string name, State state); - - Task CountByState(State state); - - Task Delete(string name); - - Task DeleteMany(string[] name); - - Task DeleteByNameAndState(string name, State state); - - Task Update(string originalName, NameEntry newEntry); - - Task CountWhere(Expression> filter); - - Task> List(int pageNumber, int pageSize, State? state, string? submittedBy); - - Task GetMetadata(); - Task> GetAllNames(State? state, string? submittedBy); + Task> FindEntryByExtendedMeaningContainingAndState(string title, State state); } } diff --git a/Core/Repositories/IUserRepository.cs b/Core/Repositories/IUserRepository.cs deleted file mode 100644 index 4864243..0000000 --- a/Core/Repositories/IUserRepository.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Core.Dto.Request; -using Core.Dto.Response; -using Core.Entities; - -namespace Core.Repositories -{ - public interface IUserRepository - { - Task GetUserByEmail(string email); - Task Create(User newUser); - Task DeleteBy(string email); - Task Update(string email, UpdateUserDto update); - Task> List(); - Task CountUsers(); - } -} diff --git a/Core/StringObjectConverters/CharacterSeparatedStringConverter.cs b/Core/StringObjectConverters/CharacterSeparatedStringConverter.cs index 93a57c1..972aa88 100644 --- a/Core/StringObjectConverters/CharacterSeparatedStringConverter.cs +++ b/Core/StringObjectConverters/CharacterSeparatedStringConverter.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; using System.Text.Json; -using Core.Dto; +using YorubaOrganization.Core.Dto; namespace Core.StringObjectConverters { diff --git a/Core/StringObjectConverters/CommaSeparatedStringConverter.cs b/Core/StringObjectConverters/CommaSeparatedStringConverter.cs index 6f5db6a..be6bb02 100644 --- a/Core/StringObjectConverters/CommaSeparatedStringConverter.cs +++ b/Core/StringObjectConverters/CommaSeparatedStringConverter.cs @@ -1,4 +1,4 @@ -using Core.Dto; +using YorubaOrganization.Core.Dto; namespace Core.StringObjectConverters { diff --git a/Core/StringObjectConverters/HyphenSeparatedStringConverter.cs b/Core/StringObjectConverters/HyphenSeparatedStringConverter.cs index 27d0405..cb64d5f 100644 --- a/Core/StringObjectConverters/HyphenSeparatedStringConverter.cs +++ b/Core/StringObjectConverters/HyphenSeparatedStringConverter.cs @@ -1,4 +1,4 @@ -using Core.Dto; +using YorubaOrganization.Core.Dto; namespace Core.StringObjectConverters { diff --git a/Core/Utilities/DiacriticsNormalizer.cs b/Core/Utilities/DiacriticsNormalizer.cs deleted file mode 100644 index 5fb35ed..0000000 --- a/Core/Utilities/DiacriticsNormalizer.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Globalization; -using System.Text; -using System.Text.RegularExpressions; - -namespace Core.Utilities -{ - public static class DiacriticsNormalizer - { - const char a = 'a', e = 'e', i = 'i', o = 'o', u = 'u'; - static readonly Dictionary vowelMap = new() - { - { 'á', a }, - { 'à', a }, - { 'a', a }, - { 'é', e }, - { 'è', e }, - { 'e', e }, - { 'ẹ', e }, - { 'í', i }, - { 'ì', i }, - { 'i', i }, - { 'ó', o }, - { 'ò', o }, - { 'o', o }, - { 'ọ', o }, - { 'ú', u }, - { 'ù', u }, - { 'u', u } - }; - - - static readonly Dictionary vowelPatterns = new() - { - // "\\p{Mn}?" means zero or one nonspacing unicode mark. It could occur after any vowel. - { 'a', "[aáà]\\p{Mn}?" }, - { 'e', "[eéèẹ]\\p{Mn}?" }, - { 'i', "[iíì]\\p{Mn}?" }, - { 'o', "[oóòọ]\\p{Mn}?" }, - { 'u', "[uúù]\\p{Mn}?" } - }; - - private static string RemoveDiacriticsAndSimplify(this string text) - { - var decomposed = text.Normalize(NormalizationForm.FormD); - var stringBuilder = new StringBuilder(); - - foreach (var c in decomposed) - { - if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) - { - if (vowelMap.TryGetValue(c, out var simpleVowel)) - { - stringBuilder.Append(simpleVowel); - } - else - { - stringBuilder.Append(c); - } - } - } - - return stringBuilder.ToString().Normalize(NormalizationForm.FormC); - } - - public static string ReplaceYorubaVowelsWithPattern(this string term) - { - term = RemoveDiacriticsAndSimplify(term); - - return string.Concat(term.Select(c => - vowelPatterns.TryGetValue(c, out var pattern) ? pattern : Regex.Escape(c.ToString()) - )); - } - } -} diff --git a/Infrastructure.MongoDB/Infrastructure.MongoDB.csproj b/Infrastructure.MongoDB/Infrastructure.MongoDB.csproj deleted file mode 100644 index fedebeb..0000000 --- a/Infrastructure.MongoDB/Infrastructure.MongoDB.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - net8.0 - enable - enable - - - - - - - - - \ No newline at end of file diff --git a/Infrastructure.MongoDB/Repositories/MongoDBRepository.cs b/Infrastructure.MongoDB/Repositories/MongoDBRepository.cs deleted file mode 100644 index 6fae029..0000000 --- a/Infrastructure.MongoDB/Repositories/MongoDBRepository.cs +++ /dev/null @@ -1,18 +0,0 @@ -using MongoDB.Driver; - -namespace Infrastructure.MongoDB.Repositories -{ - public abstract class MongoDBRepository - { - protected static T SetCollationPrimary(dynamic dbCommandOption) - { - dbCommandOption.Collation = new Collation("en", strength: CollationStrength.Primary); - return (T)dbCommandOption; - } - protected static T SetCollationSecondary(dynamic dbCommandOption) - { - dbCommandOption.Collation = new Collation("en", strength: CollationStrength.Secondary); - return (T)dbCommandOption; - } - } -} \ No newline at end of file diff --git a/Infrastructure.MongoDB/Repositories/NameEntryRepository.cs b/Infrastructure.MongoDB/Repositories/NameEntryRepository.cs deleted file mode 100644 index 73b5ee1..0000000 --- a/Infrastructure.MongoDB/Repositories/NameEntryRepository.cs +++ /dev/null @@ -1,316 +0,0 @@ -using Core.Dto.Response; -using Core.Entities.NameEntry; -using Core.Enums; -using Core.Events; -using Core.Repositories; -using Core.Utilities; -using MongoDB.Bson; -using MongoDB.Driver; -using System.Linq.Expressions; - -namespace Infrastructure.MongoDB.Repositories; - -public class NameEntryRepository : MongoDBRepository, INameEntryRepository -{ - private readonly IMongoCollection _nameEntryCollection; - private readonly IEventPubService _eventPubService; - - public NameEntryRepository( - IMongoDatabase database, - IEventPubService eventPubService) - { - _nameEntryCollection = database.GetCollection("NameEntries"); - _eventPubService = eventPubService; - - CreateIndexes(); - } - - private void CreateIndexes() - { - var indexKeys = Builders.IndexKeys.Ascending(x => x.Name); - var indexOptions = new CreateIndexOptions - { - Unique = true, - Name = "IX_NameEntries_Name_Unique", - Background = true - }; - _nameEntryCollection.Indexes.CreateOne(new CreateIndexModel(indexKeys, indexOptions)); - } - - public async Task FindById(string id) - { - return await _nameEntryCollection.Find(x => x.Id == id).SingleOrDefaultAsync(); - } - - public async Task DeleteAll() - { - var deleteResult = await _nameEntryCollection.DeleteManyAsync(FilterDefinition.Empty); - return deleteResult.DeletedCount > 0; - } - - - public async Task Create(NameEntry entry) - { - entry.Id = ObjectId.GenerateNewId().ToString(); - await _nameEntryCollection.InsertOneAsync(entry); - } - - public async Task Create(List entries) - { - entries.ForEach(entry => entry.Id = ObjectId.GenerateNewId().ToString()!); - await _nameEntryCollection.InsertManyAsync(entries); - } - - public async Task CountByState(State state) - { - return await CountWhere(ne => ne.State == state); - } - - public async Task Delete(string name) - { - var filter = Builders.Filter.Eq("Name", name); - var options = SetCollationPrimary(new DeleteOptions()); - - await _nameEntryCollection.DeleteOneAsync(filter, options); - } - public async Task DeleteMany(string[] names) - { - var filter = Builders.Filter.In("Name", names); - var options = SetCollationPrimary(new DeleteOptions()); - - await _nameEntryCollection.DeleteManyAsync(filter, options); - } - - public async Task DeleteByNameAndState(string name, State state) - { - var filter = Builders - .Filter - .And(Builders.Filter.Eq("Name", name), Builders.Filter.Eq("State", state)); - - var options = SetCollationPrimary(new DeleteOptions()); - - var deleteResult = await _nameEntryCollection.DeleteOneAsync(filter, options); - - return deleteResult.DeletedCount > 0; - } - - // TODO Hafiz (Later): This is pulling too much data. We should eventually get rid of it. - public async Task> ListAll() - { - var allEntries = await _nameEntryCollection.Find(_ => true).ToListAsync(); - return new HashSet(allEntries); - } - - public async Task FindByName(string name) - { - var filter = Builders.Filter.Eq("Name", name); - var options = SetCollationPrimary(new FindOptions()); - - return await _nameEntryCollection.Find(filter, options).SingleOrDefaultAsync(); - } - - public async Task> FindByNames(string[] names) - { - var filter = Builders.Filter.In("Name", names); - var options = SetCollationPrimary(new FindOptions()); - - return await _nameEntryCollection.Find(filter, options).ToListAsync(); - } - - public async Task FindByNameAndState(string name, State state) - { - var options = SetCollationPrimary(new FindOptions()); - return await _nameEntryCollection - .Find(ne => ne.Name == name && ne.State == state, options) - .SingleOrDefaultAsync(); - } - - public async Task> FindByNameStartingWithAndState(string searchTerm, State state) - { - return await FindByNameStartingWithAnyAndState(new string[] { searchTerm }, state); - } - - public async Task> FindByNameStartingWithAnyAndState(IEnumerable searchTerms, State state) - { - var regexFilters = searchTerms.Select(term => - { - return Builders.Filter.Regex(ne => ne.Name, - new BsonRegularExpression($"^{term.ReplaceYorubaVowelsWithPattern()}", "i")); - }); - - var namesFilter = Builders.Filter.Or(regexFilters); - var stateFilter = Builders.Filter.Eq(ne => ne.State, state); - var combinedFilter = Builders.Filter.And(namesFilter, stateFilter); - - var result = await _nameEntryCollection.Find(combinedFilter).ToListAsync(); - return new HashSet(result); - } - - - public async Task> FindByState(State state) - { - return await _nameEntryCollection.Find(ne => ne.State == state).ToListAsync(); - } - - public async Task> FindNameEntryByExtendedMeaningContainingAndState(string name, State state) - { - var filter = Builders.Filter.Regex(ne => ne.ExtendedMeaning, - new BsonRegularExpression(name.ReplaceYorubaVowelsWithPattern(), "i")) & - Builders.Filter.Eq(ne => ne.State, state); - var result = await _nameEntryCollection.Find(filter).ToListAsync(); - return new HashSet(result); - } - - public async Task> FindNameEntryByMeaningContainingAndState(string name, State state) - { - var filter = Builders.Filter.Regex(ne => ne.Name, - new BsonRegularExpression(name.ReplaceYorubaVowelsWithPattern(), "i")) - & Builders.Filter.Eq(ne => ne.State, state); - var result = await _nameEntryCollection.Find(filter).ToListAsync(); - return new HashSet(result); - } - - public async Task> FindNameEntryByNameContainingAndState(string name, State state) - { - var filter = Builders.Filter.Regex(ne => ne.Name, - new BsonRegularExpression(name.ReplaceYorubaVowelsWithPattern(), "i")) & - Builders.Filter.Eq(ne => ne.State, state); - var result = await _nameEntryCollection.Find(filter).ToListAsync(); - return new HashSet(result); - } - - public async Task> FindNameEntryByVariantsContainingAndState(string name, State state) - { - var regex = new BsonRegularExpression(name.ReplaceYorubaVowelsWithPattern(), "i"); - var filter = Builders.Filter.Regex(ne => ne.Variants, regex) & Builders.Filter.Eq(ne => ne.State, state); - var result = await _nameEntryCollection.Find(filter).ToListAsync(); - return new HashSet(result); - } - - public async Task Update(string originalName, NameEntry newEntry) - { - var filter = Builders.Filter.Eq(ne => ne.Name, originalName); - var updateStatement = GenerateUpdateStatement(newEntry); - - var options = new FindOneAndUpdateOptions - { - ReturnDocument = ReturnDocument.After - }; - - var updated = await _nameEntryCollection.FindOneAndUpdateAsync(filter, updateStatement, options); - - if (updated == null) - { - await _eventPubService.PublishEvent(new NonExistingNameUpdateAttempted(originalName)); - } - else if (originalName != newEntry.Name) - { - await _eventPubService.PublishEvent(new NameEntryNameUpdated(originalName, newEntry.Name)); - } - - return updated; - } - - public async Task CountWhere(Expression> filter) - { - var count = await _nameEntryCollection.CountDocumentsAsync(filter); - return (int)count; - } - - public async Task> GetAllNames(State? state, string? submittedBy) - { - var filterBuilder = Builders.Filter; - var filter = filterBuilder.Empty; - - if (state.HasValue) - { - filter &= filterBuilder.Eq(ne => ne.State, state.Value); - } - - if (!string.IsNullOrWhiteSpace(submittedBy)) - { - filter &= filterBuilder.Eq(ne => ne.CreatedBy, submittedBy.Trim()); - } - - var projection = Builders.Projection.Include(ne => ne.Name).Exclude(ne => ne.Id); - - var names = await _nameEntryCollection - .Find(filter) - .Project(projection) - .Sort(Builders.Sort.Ascending(ne => ne.CreatedAt)) - .ToListAsync(); - - return names; - } - - public async Task> List(int pageNumber, int pageSize, State? state, string? submittedBy) - { - var filterBuilder = Builders.Filter; - var filter = filterBuilder.Empty; - - if (state.HasValue) - { - filter &= filterBuilder.Eq(ne => ne.State, state.Value); - } - - if (!string.IsNullOrWhiteSpace(submittedBy)) - { - filter &= filterBuilder.Eq(ne => ne.CreatedBy, submittedBy.Trim()); - } - - int skip = (pageNumber - 1) * pageSize; - var names = await _nameEntryCollection - .Find(filter) - .Skip(skip) - .Limit(pageSize) - .Sort(Builders.Sort.Ascending(ne => ne.CreatedAt)) - .ToListAsync(); - return names; - } - - public async Task GetMetadata() - { - return new NamesMetadataDto - { - TotalNames = await _nameEntryCollection.CountDocumentsAsync(FilterDefinition.Empty), - TotalNewNames = await _nameEntryCollection.CountDocumentsAsync(Builders.Filter.Eq(x => x.State, State.NEW)), - TotalModifiedNames = await _nameEntryCollection.CountDocumentsAsync(Builders.Filter.Eq(x => x.State, State.MODIFIED)), - TotalPublishedNames = await _nameEntryCollection.CountDocumentsAsync(Builders.Filter.Eq(x => x.State, State.PUBLISHED)) - }; - } - - private static UpdateDefinition GenerateUpdateStatement(NameEntry newEntry) - { - var statement = Builders.Update - .Set(ne => ne.Name, newEntry.Name) - .Set(ne => ne.State, newEntry.State) - .Set(ne => ne.Pronunciation, newEntry.Pronunciation) - .Set(ne => ne.IpaNotation, newEntry.IpaNotation) - .Set(ne => ne.Meaning, newEntry.Meaning) - .Set(ne => ne.ExtendedMeaning, newEntry.ExtendedMeaning) - .Set(ne => ne.Morphology, newEntry.Morphology) - .Set(ne => ne.Media, newEntry.Media) - .Set(ne => ne.State, newEntry.State) - .Set(ne => ne.Etymology, newEntry.Etymology) - .Set(ne => ne.Videos, newEntry.Videos) - .Set(ne => ne.GeoLocation, newEntry.GeoLocation) - .Set(ne => ne.FamousPeople, newEntry.FamousPeople) - .Set(ne => ne.Syllables, newEntry.Syllables) - .Set(ne => ne.Variants, newEntry.Variants) - .Set(ne => ne.Modified, newEntry.Modified) - .Set(ne => ne.Duplicates, newEntry.Duplicates) - .CurrentDate(ne => ne.UpdatedAt); - - if (!string.IsNullOrWhiteSpace(newEntry.UpdatedBy)) - { - statement = statement.Set(ne => ne.UpdatedBy, newEntry.UpdatedBy); - } - - if (newEntry.Duplicates.Any()) - { - statement = statement.Set(ne => ne.Duplicates, newEntry.Duplicates); - } - - return statement; - } -} \ No newline at end of file diff --git a/Infrastructure/Hangfire/DependencyInjection.cs b/Infrastructure/Hangfire/DependencyInjection.cs index 499775c..5f9bbfe 100644 --- a/Infrastructure/Hangfire/DependencyInjection.cs +++ b/Infrastructure/Hangfire/DependencyInjection.cs @@ -5,7 +5,6 @@ using Hangfire.Mongo.Migration.Strategies.Backup; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Builder; -using Api.Utilities; using Ardalis.GuardClauses; namespace Infrastructure.Hangfire diff --git a/Infrastructure/Hangfire/HangfireAuthFilter.cs b/Infrastructure/Hangfire/HangfireAuthFilter.cs index ad7fd43..3d99aeb 100644 --- a/Infrastructure/Hangfire/HangfireAuthFilter.cs +++ b/Infrastructure/Hangfire/HangfireAuthFilter.cs @@ -1,6 +1,6 @@ using Hangfire.Dashboard; -namespace Api.Utilities +namespace Infrastructure.Hangfire { public class HangfireAuthFilter : IDashboardAuthorizationFilter { diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj index 6b0bce6..57f13f0 100644 --- a/Infrastructure/Infrastructure.csproj +++ b/Infrastructure/Infrastructure.csproj @@ -7,14 +7,16 @@ - - - + + + - + + + diff --git a/Infrastructure.MongoDB/DependencyInjection.cs b/Infrastructure/MongoDB/DependencyInjection.cs similarity index 96% rename from Infrastructure.MongoDB/DependencyInjection.cs rename to Infrastructure/MongoDB/DependencyInjection.cs index 58b8204..b64da57 100644 --- a/Infrastructure.MongoDB/DependencyInjection.cs +++ b/Infrastructure/MongoDB/DependencyInjection.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Core.Repositories; using Infrastructure.MongoDB.Repositories; +using YorubaOrganization.Core.Repositories; namespace Infrastructure.MongoDB { diff --git a/Infrastructure.MongoDB/Repositories/EtymologyRepository.cs b/Infrastructure/MongoDB/Repositories/EtymologyRepository.cs similarity index 86% rename from Infrastructure.MongoDB/Repositories/EtymologyRepository.cs rename to Infrastructure/MongoDB/Repositories/EtymologyRepository.cs index d47a1df..579e725 100644 --- a/Infrastructure.MongoDB/Repositories/EtymologyRepository.cs +++ b/Infrastructure/MongoDB/Repositories/EtymologyRepository.cs @@ -1,8 +1,9 @@ -using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; -using Core.Events; -using Core.Repositories; +using Core.Entities; using MongoDB.Driver; +using YorubaOrganization.Core.Entities.Partials; +using YorubaOrganization.Core.Events; +using YorubaOrganization.Core.Repositories; +using YorubaOrganization.Infrastructure.Repositories; namespace Infrastructure.MongoDB.Repositories; diff --git a/Infrastructure.MongoDB/Repositories/GeoLocationsRepository.cs b/Infrastructure/MongoDB/Repositories/GeoLocationsRepository.cs similarity index 97% rename from Infrastructure.MongoDB/Repositories/GeoLocationsRepository.cs rename to Infrastructure/MongoDB/Repositories/GeoLocationsRepository.cs index c50ccc5..ef5aa04 100644 --- a/Infrastructure.MongoDB/Repositories/GeoLocationsRepository.cs +++ b/Infrastructure/MongoDB/Repositories/GeoLocationsRepository.cs @@ -1,7 +1,8 @@ -using Core.Entities; -using Core.Repositories; -using MongoDB.Bson; +using MongoDB.Bson; using MongoDB.Driver; +using YorubaOrganization.Core.Entities; +using YorubaOrganization.Core.Repositories; +using YorubaOrganization.Infrastructure.Repositories; namespace Infrastructure.MongoDB.Repositories { diff --git a/Infrastructure.MongoDB/Repositories/NameEntryFeedbackRepository.cs b/Infrastructure/MongoDB/Repositories/NameEntryFeedbackRepository.cs similarity index 63% rename from Infrastructure.MongoDB/Repositories/NameEntryFeedbackRepository.cs rename to Infrastructure/MongoDB/Repositories/NameEntryFeedbackRepository.cs index 169710f..17c0618 100644 --- a/Infrastructure.MongoDB/Repositories/NameEntryFeedbackRepository.cs +++ b/Infrastructure/MongoDB/Repositories/NameEntryFeedbackRepository.cs @@ -1,9 +1,9 @@ using Core.Dto.Response; -using Core.Entities.NameEntry; -using Core.Entities.NameEntry.Collections; +using Core.Entities; using Core.Repositories; using MongoDB.Bson; using MongoDB.Driver; +using YorubaOrganization.Core.Entities.Partials; namespace Infrastructure.MongoDB.Repositories; @@ -16,44 +16,45 @@ public NameEntryFeedbackRepository(IMongoDatabase database) _nameEntryCollection = database.GetCollection("NameEntries"); } - public async Task> FindAllAsync() + public async Task> FindAllAsync() { + var feedbacksFieldName = nameof(NameEntry.Feedbacks); var pipeline = new BsonDocument[] { - new BsonDocument("$unwind", "$Feedbacks"), - new BsonDocument("$project", new BsonDocument + new("$unwind", $"${feedbacksFieldName}"), + new("$project", new BsonDocument { - { "Name", "$Name" }, - { "Feedback", "$Feedbacks.Content" }, - { "SubmittedAt", "$Feedbacks.CreatedAt" }, - { "_id", "$Feedbacks._id" } + { "Name", $"${nameof(NameEntry.Title)}" }, + { "Feedback", $"${feedbacksFieldName}.{nameof(Feedback.Content)}" }, + { $"{nameof(NameFeedbackDto.SubmittedAt)}", $"${feedbacksFieldName}.{nameof(Feedback.CreatedAt)}" }, + { "_id", $"${feedbacksFieldName}._id" } }), - new BsonDocument("$sort", new BsonDocument("SubmittedAt", -1)) + new("$sort", new BsonDocument($"{nameof(NameFeedbackDto.SubmittedAt)}", -1)) }; var feedbacks = await _nameEntryCollection - .Aggregate(pipeline) + .Aggregate(pipeline) .ToListAsync(); return feedbacks; } - public async Task> FindByNameAsync(string name) + public async Task> FindByNameAsync(string name) { var theName = await _nameEntryCollection - .Find(entry => entry.Name.ToLower() == name.ToLower()) + .Find(entry => entry.Title.ToLower() == name.ToLower()) .SingleOrDefaultAsync(); - var feedbacksForName = theName?.Feedbacks?.Select(f => new FeedbackDto(f.Id, theName.Name, f.Content!, f.CreatedAt)) + var feedbacksForName = theName?.Feedbacks?.Select(f => new NameFeedbackDto(f.Id, theName.Title, f.Content!, f.CreatedAt)) .OrderByDescending(x => x.SubmittedAt) .ToList(); - return feedbacksForName ?? new List(); + return feedbacksForName ?? new List(); } public async Task AddFeedbackByNameAsync(string name, string feedbackContent) { - var filter = Builders.Filter.Where(x => x.Name.ToLower() == name.ToLower()); + var filter = Builders.Filter.Where(x => x.Title.ToLower() == name.ToLower()); var nameFeedback = new Feedback { @@ -69,7 +70,7 @@ public async Task AddFeedbackByNameAsync(string name, string feedbackContent) public async Task DeleteAllFeedbackForNameAsync(string name) { - var filter = Builders.Filter.Where(x => x.Name.ToLower() == name.ToLower()); + var filter = Builders.Filter.Where(x => x.Title.ToLower() == name.ToLower()); var update = Builders.Update.Set(entry => entry.Feedbacks, new List()); await _nameEntryCollection.UpdateOneAsync(filter, update); @@ -77,7 +78,7 @@ public async Task DeleteAllFeedbackForNameAsync(string name) public async Task DeleteFeedbackAsync(string name, string feedbackId) { - var filter = Builders.Filter.Where(x => x.Name.ToLower() == name.ToLower()); + var filter = Builders.Filter.Where(x => x.Title.ToLower() == name.ToLower()); var entry = await _nameEntryCollection.Find(filter).FirstOrDefaultAsync(); if (entry != null && entry.Feedbacks.Any(feedback => feedback.Id == feedbackId)) @@ -91,12 +92,12 @@ public async Task DeleteFeedbackAsync(string name, string feedbackId) return false; } - public async Task GetFeedbackByIdAsync(string feedbackId) + public async Task GetFeedbackByIdAsync(string feedbackId) { var filter = Builders.Filter.ElemMatch(entry => entry.Feedbacks, feedback => feedback.Id == feedbackId); var projectionDefinition = Builders .Projection - .Include(entry => entry.Name) + .Include(entry => entry.Title) .ElemMatch(entry => entry.Feedbacks, feedback => feedback.Id == feedbackId); var nameEntry = await _nameEntryCollection.Find(filter) @@ -104,7 +105,7 @@ public async Task DeleteFeedbackAsync(string name, string feedbackId) .FirstOrDefaultAsync(); var theMatch = nameEntry?.Feedbacks?.FirstOrDefault(feedback => feedback.Id == feedbackId); - return theMatch == null ? null : new FeedbackDto(theMatch.Id, nameEntry!.Name, theMatch!.Content!, theMatch.CreatedAt); + return theMatch == null ? null : new NameFeedbackDto(theMatch.Id, nameEntry!.Title, theMatch!.Content!, theMatch.CreatedAt); } } diff --git a/Infrastructure/MongoDB/Repositories/NameEntryRepository.cs b/Infrastructure/MongoDB/Repositories/NameEntryRepository.cs new file mode 100644 index 0000000..7d29146 --- /dev/null +++ b/Infrastructure/MongoDB/Repositories/NameEntryRepository.cs @@ -0,0 +1,34 @@ +using Core.Entities; +using Core.Repositories; +using MongoDB.Bson; +using MongoDB.Driver; +using YorubaOrganization.Core.Enums; +using YorubaOrganization.Core.Events; +using YorubaOrganization.Core.Utilities; +using YorubaOrganization.Infrastructure.Repositories; + +namespace Infrastructure.MongoDB.Repositories; + +public sealed class NameEntryRepository : DictionaryEntryRepository, INameEntryRepository +{ + private const string CollectionName = "NameEntries"; + public NameEntryRepository( + IMongoDatabase database, + IEventPubService eventPubService) : base(CollectionName, database, eventPubService) + { + } + + public async Task> FindEntryByExtendedMeaningContainingAndState(string name, State state) + { + var filter = Builders.Filter.Regex(ne => ne.ExtendedMeaning, + new BsonRegularExpression(name.ReplaceYorubaVowelsWithPattern(), "i")) & + Builders.Filter.Eq(ne => ne.State, state); + var result = await EntryCollection.Find(filter).ToListAsync(); + return new HashSet(result); + } + + protected override UpdateDefinition GenerateCustomUpdateStatement(NameEntry newEntry) => Builders.Update + .Set(ne => ne.Meaning, newEntry.Meaning) + .Set(ne => ne.ExtendedMeaning, newEntry.ExtendedMeaning) + .Set(ne => ne.FamousPeople, newEntry.FamousPeople); +} \ No newline at end of file diff --git a/Infrastructure.MongoDB/Repositories/SuggestedNameRepository.cs b/Infrastructure/MongoDB/Repositories/SuggestedNameRepository.cs similarity index 99% rename from Infrastructure.MongoDB/Repositories/SuggestedNameRepository.cs rename to Infrastructure/MongoDB/Repositories/SuggestedNameRepository.cs index b616a2a..664efa9 100644 --- a/Infrastructure.MongoDB/Repositories/SuggestedNameRepository.cs +++ b/Infrastructure/MongoDB/Repositories/SuggestedNameRepository.cs @@ -1,6 +1,5 @@ using Core.Entities; using Core.Repositories; -using MongoDB.Bson; using MongoDB.Driver; namespace Infrastructure.MongoDB.Repositories; diff --git a/Infrastructure.MongoDB/Repositories/UserRepository.cs b/Infrastructure/MongoDB/Repositories/UserRepository.cs similarity index 93% rename from Infrastructure.MongoDB/Repositories/UserRepository.cs rename to Infrastructure/MongoDB/Repositories/UserRepository.cs index 0fc0409..2f174d7 100644 --- a/Infrastructure.MongoDB/Repositories/UserRepository.cs +++ b/Infrastructure/MongoDB/Repositories/UserRepository.cs @@ -1,9 +1,10 @@ -using Core.Dto.Request; -using Core.Dto.Response; -using Core.Entities; -using Core.Repositories; -using MongoDB.Bson; +using MongoDB.Bson; using MongoDB.Driver; +using YorubaOrganization.Core.Dto.Request; +using YorubaOrganization.Core.Dto.Response; +using YorubaOrganization.Core.Entities; +using YorubaOrganization.Core.Repositories; +using YorubaOrganization.Infrastructure.Repositories; namespace Infrastructure.MongoDB.Repositories { diff --git a/Infrastructure/Redis/DependencyInjection.cs b/Infrastructure/Redis/DependencyInjection.cs index 2e7c906..d67c2dd 100644 --- a/Infrastructure/Redis/DependencyInjection.cs +++ b/Infrastructure/Redis/DependencyInjection.cs @@ -1,9 +1,9 @@ using Ardalis.GuardClauses; -using Core.Cache; using Infrastructure.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; +using YorubaOrganization.Core.Cache; namespace Infrastructure.Redis { diff --git a/Infrastructure/Redis/RedisRecentIndexesCache.cs b/Infrastructure/Redis/RedisRecentIndexesCache.cs index 755fee1..0f502c1 100644 --- a/Infrastructure/Redis/RedisRecentIndexesCache.cs +++ b/Infrastructure/Redis/RedisRecentIndexesCache.cs @@ -1,7 +1,7 @@ -using Core.Cache; -using Infrastructure.Configuration; +using Infrastructure.Configuration; using Microsoft.Extensions.Options; using StackExchange.Redis; +using YorubaOrganization.Core.Cache; namespace Infrastructure.Redis { diff --git a/Infrastructure/Redis/RedisRecentSearchesCache.cs b/Infrastructure/Redis/RedisRecentSearchesCache.cs index e34ad2b..f22a510 100644 --- a/Infrastructure/Redis/RedisRecentSearchesCache.cs +++ b/Infrastructure/Redis/RedisRecentSearchesCache.cs @@ -1,7 +1,7 @@ -using Core.Cache; -using Infrastructure.Configuration; +using Infrastructure.Configuration; using Microsoft.Extensions.Options; using StackExchange.Redis; +using YorubaOrganization.Core.Cache; namespace Infrastructure.Redis { diff --git a/Infrastructure/Redis/SimpleRedisCache.cs b/Infrastructure/Redis/SimpleRedisCache.cs index e1e3cf1..7e86230 100644 --- a/Infrastructure/Redis/SimpleRedisCache.cs +++ b/Infrastructure/Redis/SimpleRedisCache.cs @@ -1,8 +1,8 @@ using Ardalis.GuardClauses; -using Core.Cache; using Infrastructure.Configuration; using Microsoft.Extensions.Options; using StackExchange.Redis; +using YorubaOrganization.Core.Cache; namespace Infrastructure.Redis { public class SimpleRedisCache(IConnectionMultiplexer connectionMultiplexer, IOptions redisConfig) : diff --git a/Infrastructure/TweetsV2Poster.cs b/Infrastructure/TweetsV2Poster.cs index 7d6637a..01b2de6 100644 --- a/Infrastructure/TweetsV2Poster.cs +++ b/Infrastructure/TweetsV2Poster.cs @@ -1,5 +1,4 @@ using System.Text; -using Tweetinvi.Core.Web; using Tweetinvi.Models; using Tweetinvi; using Newtonsoft.Json; diff --git a/Infrastructure/Twitter/TwitterService.cs b/Infrastructure/Twitter/TwitterService.cs index 0677d41..6159e0d 100644 --- a/Infrastructure/Twitter/TwitterService.cs +++ b/Infrastructure/Twitter/TwitterService.cs @@ -4,7 +4,7 @@ using System.Diagnostics; using Hangfire; using Infrastructure.Configuration; -using Core.Cache; +using YorubaOrganization.Core.Cache; namespace Infrastructure.Twitter { diff --git a/README.md b/README.md index f528172..364ba9d 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ To run the Website and API locally, follow these steps: 5. **Access the Application:** - Once the Docker containers are up and running, you can access the Website in your browser. The website should launch automatically as soon as all the containers are ready. - If the Website does not launch automatically, it will be running at `http://localhost:{port}` (the actual port will be displayed in the output window of Visual Studio: "Container Tools"). - - The API will also be running locally and accessible via a different URL. You can see its documentation and test the endpoints at `http://localhost:51515/swagger`. + - The API will also be running locally and accessible via a different URL. You can see its documentation and test the endpoints at `http://localhost:9001/swagger`. - You can login to the API with any username from [the database initialization script](./mongo-init.js) and password: **Password@135**. ## Contributing diff --git a/Test/Integration/NameController/Data/NamesAllTestData.cs b/Test/Integration/NameController/Data/NamesAllTestData.cs index 0e87dbf..7cd2bf8 100644 --- a/Test/Integration/NameController/Data/NamesAllTestData.cs +++ b/Test/Integration/NameController/Data/NamesAllTestData.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using AutoFixture; -using Core.Entities.NameEntry; -using Core.Enums; +using Core.Entities; +using YorubaOrganization.Core.Enums; namespace Test.Integration.NameController.Data; diff --git a/Test/Integration/NameController/Data/NamesCountAndSubmittedByTestData.cs b/Test/Integration/NameController/Data/NamesCountAndSubmittedByTestData.cs index e3ea1b4..f918fcf 100644 --- a/Test/Integration/NameController/Data/NamesCountAndSubmittedByTestData.cs +++ b/Test/Integration/NameController/Data/NamesCountAndSubmittedByTestData.cs @@ -1,7 +1,7 @@ using System.Collections; using System.Collections.Generic; using AutoFixture; -using Core.Entities.NameEntry; +using Core.Entities; namespace Test.Integration.NameController.Data { diff --git a/Test/Integration/NameController/Data/NamesCountTestData.cs b/Test/Integration/NameController/Data/NamesCountTestData.cs index 77b1d3b..61da9d7 100644 --- a/Test/Integration/NameController/Data/NamesCountTestData.cs +++ b/Test/Integration/NameController/Data/NamesCountTestData.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using AutoFixture; -using Core.Entities.NameEntry; -using Core.Enums; +using Core.Entities; +using YorubaOrganization.Core.Enums; namespace Test.Integration.NameController.Data; public class NamesCountTestData : IEnumerable @@ -20,7 +20,7 @@ public NamesCountTestData() .With(ne => ne.State, State.PUBLISHED) .With(ne => ne.Modified, (NameEntry?)default) .With(ne => ne.Duplicates, []) - .Do(ne => ne.Name = GetNextName())); + .Do(ne => ne.Title = GetNextName())); } public IEnumerator GetEnumerator() diff --git a/Test/Integration/NameController/Data/NamesStateAndCountTestData.cs b/Test/Integration/NameController/Data/NamesStateAndCountTestData.cs index d350431..ae90518 100644 --- a/Test/Integration/NameController/Data/NamesStateAndCountTestData.cs +++ b/Test/Integration/NameController/Data/NamesStateAndCountTestData.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using AutoFixture; -using Core.Entities.NameEntry; -using Core.Enums; +using Core.Entities; +using YorubaOrganization.Core.Enums; namespace Test.Integration.NameController.Data; diff --git a/Test/Integration/NameController/Data/NamesStateAndSubmittedByTestData.cs b/Test/Integration/NameController/Data/NamesStateAndSubmittedByTestData.cs index f033572..e4939b5 100644 --- a/Test/Integration/NameController/Data/NamesStateAndSubmittedByTestData.cs +++ b/Test/Integration/NameController/Data/NamesStateAndSubmittedByTestData.cs @@ -1,8 +1,8 @@ using System.Collections; using System.Collections.Generic; using AutoFixture; -using Core.Entities.NameEntry; -using Core.Enums; +using Core.Entities; +using YorubaOrganization.Core.Enums; namespace Test.Integration.NameController.Data; diff --git a/Test/Integration/NameController/Data/NamesStateCountAndSubmittedByTestData.cs b/Test/Integration/NameController/Data/NamesStateCountAndSubmittedByTestData.cs index 39f769c..e85ac0c 100644 --- a/Test/Integration/NameController/Data/NamesStateCountAndSubmittedByTestData.cs +++ b/Test/Integration/NameController/Data/NamesStateCountAndSubmittedByTestData.cs @@ -1,8 +1,8 @@ using System.Collections; using System.Collections.Generic; using AutoFixture; -using Core.Entities.NameEntry; -using Core.Enums; +using Core.Entities; +using YorubaOrganization.Core.Enums; namespace Test.Integration.NameController.Data; diff --git a/Test/Integration/NameController/Data/NamesStateTestData.cs b/Test/Integration/NameController/Data/NamesStateTestData.cs index ce0c61b..6e96808 100644 --- a/Test/Integration/NameController/Data/NamesStateTestData.cs +++ b/Test/Integration/NameController/Data/NamesStateTestData.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using AutoFixture; -using Core.Entities.NameEntry; -using Core.Enums; +using Core.Entities; +using YorubaOrganization.Core.Enums; namespace Test.Integration.NameController.Data; diff --git a/Test/Integration/NameController/Data/NamesSubmittedByTestData.cs b/Test/Integration/NameController/Data/NamesSubmittedByTestData.cs index 12b9acb..a1faaea 100644 --- a/Test/Integration/NameController/Data/NamesSubmittedByTestData.cs +++ b/Test/Integration/NameController/Data/NamesSubmittedByTestData.cs @@ -1,8 +1,8 @@ using System.Collections; using System.Collections.Generic; using AutoFixture; -using Core.Entities.NameEntry; -using Core.Enums; +using Core.Entities; +using YorubaOrganization.Core.Enums; namespace Test.Integration.NameController.Data; diff --git a/Test/Integration/NameController/NameControllerTest.cs b/Test/Integration/NameController/NameControllerTest.cs index 7d511ac..bce0075 100644 --- a/Test/Integration/NameController/NameControllerTest.cs +++ b/Test/Integration/NameController/NameControllerTest.cs @@ -1,5 +1,4 @@ using Core.Dto.Response; -using Core.Entities.NameEntry; using Core.Repositories; using System.Collections.Generic; using System.Net.Http; @@ -10,8 +9,9 @@ using Xunit; using System.Linq; using Microsoft.Extensions.DependencyInjection; -using Core.Enums; using FluentAssertions; +using Core.Entities; +using YorubaOrganization.Core.Enums; namespace Test.Integration.NameController { @@ -53,7 +53,7 @@ public async Task TestGetAllNamesEndpointProvidingOnlyAllParameter(List dto.Name) .Should() - .BeEquivalentTo(seed.Select(s => s.Name)); + .BeEquivalentTo(seed.Select(s => s.Title)); } [Theory] @@ -76,7 +76,7 @@ public async Task TestGetAllNamesEndpointProvidingOnlyState(List seed nameEntryDtos .Select(dto => dto.Name) .Should() - .BeEquivalentTo(filteredSeed.Select(s => s.Name)); + .BeEquivalentTo(filteredSeed.Select(s => s.Title)); } [Theory] @@ -98,7 +98,7 @@ public async Task TestGetAllNamesEndpointProvidingOnlyCount(List seed namesResponse .Select(dto => dto.Name) .Should() - .BeSubsetOf(seed.Select(s => s.Name)); + .BeSubsetOf(seed.Select(s => s.Title)); } [Theory] @@ -121,7 +121,7 @@ public async Task TestGetAllNamesEndpointProvidingOnlySubmittedBy(List dto.Name) .Should() - .Equal(expectedData.Select(ed => ed.Name)); + .Equal(expectedData.Select(ed => ed.Title)); } [Theory] @@ -145,7 +145,7 @@ public async Task TestGetAllNamesEndpointProvidingOnlyStateAndCount(List dto.Name) .Should() - .BeEquivalentTo(filteredSeed.Select(s => s.Name)); + .BeEquivalentTo(filteredSeed.Select(s => s.Title)); } [Theory] @@ -168,7 +168,7 @@ public async Task TestGetAllNamesEndpointProvidingOnlyStateAndSubmittedBy(List dto.Name) .Should() - .BeEquivalentTo(filteredSeed.Select(s => s.Name)); + .BeEquivalentTo(filteredSeed.Select(s => s.Title)); } [Theory] @@ -192,7 +192,7 @@ public async Task TestGetAllNamesEndpointProvidingOnlyCountAndSubmittedBy(List dto.Name) .Should() - .BeEquivalentTo(filteredSeed.Select(s => s.Name)); + .BeEquivalentTo(filteredSeed.Select(s => s.Title)); } [Theory] @@ -216,7 +216,7 @@ public async Task TestGetAllNamesEndpointProvidingOnlyState_CountAndSubmittedBy( namesResponse .Select(dto => dto.Name) .Should() - .BeEquivalentTo(filteredSeed.Select(s => s.Name)); + .BeEquivalentTo(filteredSeed.Select(s => s.Title)); } public Task InitializeAsync() diff --git a/Website/Pages/SearchResults.cshtml.cs b/Website/Pages/SearchResults.cshtml.cs index e6d2e21..75f715c 100644 --- a/Website/Pages/SearchResults.cshtml.cs +++ b/Website/Pages/SearchResults.cshtml.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; using System.Globalization; -using System.Text.Json; using Website.Pages.Shared; using Website.Resources; using Website.Services; diff --git a/Website/Pages/Shared/BasePageModel.cshtml.cs b/Website/Pages/Shared/BasePageModel.cshtml.cs index f6df708..dc4d877 100644 --- a/Website/Pages/Shared/BasePageModel.cshtml.cs +++ b/Website/Pages/Shared/BasePageModel.cshtml.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Localization; -using System.Web; using Website.Resources; namespace Website.Pages.Shared diff --git a/Website/Pages/SubmitName.cshtml.cs b/Website/Pages/SubmitName.cshtml.cs index 886918d..26920b4 100644 --- a/Website/Pages/SubmitName.cshtml.cs +++ b/Website/Pages/SubmitName.cshtml.cs @@ -1,10 +1,9 @@ -using Application.Services; -using Core.Dto.Response; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; using Website.Pages.Shared; using Website.Resources; using Website.Services; +using YorubaOrganization.Core.Dto.Response; namespace Website.Pages { diff --git a/Website/Services/ApiService.cs b/Website/Services/ApiService.cs index e924411..61ef1b9 100644 --- a/Website/Services/ApiService.cs +++ b/Website/Services/ApiService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Options; using System.Text.Json; using Website.Config; +using YorubaOrganization.Core.Dto.Response; namespace Website.Services { diff --git a/YorubaNameDictionary.sln b/YorubaNameDictionary.sln index 6bbaa85..b254de3 100644 --- a/YorubaNameDictionary.sln +++ b/YorubaNameDictionary.sln @@ -5,8 +5,6 @@ VisualStudioVersion = 17.7.34221.43 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "Core\Core.csproj", "{12B35F2A-85F6-45C5-B1E9-84089F529ED6}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.MongoDB", "Infrastructure.MongoDB\Infrastructure.MongoDB.csproj", "{2C8E50D5-BE74-4370-B0CA-156C16D00AA4}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application", "Application\Application.csproj", "{6FBF4BDD-D321-4B30-BA9C-70B1B0320361}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Api", "Api\Api.csproj", "{6DF0AC0B-335E-44A3-AD11-5DC7A12602D4}" @@ -27,7 +25,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{78930A71-3759-46B3-9429-80C4D4933D12}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{78930A71-3759-46B3-9429-80C4D4933D12}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -39,10 +37,6 @@ Global {12B35F2A-85F6-45C5-B1E9-84089F529ED6}.Debug|Any CPU.Build.0 = Debug|Any CPU {12B35F2A-85F6-45C5-B1E9-84089F529ED6}.Release|Any CPU.ActiveCfg = Release|Any CPU {12B35F2A-85F6-45C5-B1E9-84089F529ED6}.Release|Any CPU.Build.0 = Release|Any CPU - {2C8E50D5-BE74-4370-B0CA-156C16D00AA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2C8E50D5-BE74-4370-B0CA-156C16D00AA4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2C8E50D5-BE74-4370-B0CA-156C16D00AA4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2C8E50D5-BE74-4370-B0CA-156C16D00AA4}.Release|Any CPU.Build.0 = Release|Any CPU {6FBF4BDD-D321-4B30-BA9C-70B1B0320361}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6FBF4BDD-D321-4B30-BA9C-70B1B0320361}.Debug|Any CPU.Build.0 = Debug|Any CPU {6FBF4BDD-D321-4B30-BA9C-70B1B0320361}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/docker-compose.override.yml b/docker-compose.override.yml index b594ccd..857958e 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -19,7 +19,7 @@ services: - Twitter__AccessToken=${YND_Twitter__AccessToken} - Twitter__AccessTokenSecret=${YND_Twitter__AccessTokenSecret} ports: - - "51515:80" + - "9001:80" volumes: - ${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/usersecrets:ro - ${APPDATA}/ASP.NET/Https:/root/.aspnet/https:ro From 5f094090186e08a64bb46da290932049b96ee7b7 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Tue, 24 Dec 2024 23:44:42 +0200 Subject: [PATCH 20/20] Add an ads.txt file. (#116) --- Website/wwwroot/ads.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 Website/wwwroot/ads.txt diff --git a/Website/wwwroot/ads.txt b/Website/wwwroot/ads.txt new file mode 100644 index 0000000..74aa582 --- /dev/null +++ b/Website/wwwroot/ads.txt @@ -0,0 +1 @@ +google.com, pub-4685601534719932, DIRECT, f08c47fec0942fa0 \ No newline at end of file