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 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/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/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/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/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/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/Mappers/NameEntryMapper.cs b/Application/Mappers/NameEntryMapper.cs index 7a00260..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(), @@ -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..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, @@ -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/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/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/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 8dbb1bd..233e46e 100644 --- a/Application/Validation/GeoLocationValidator.cs +++ b/Application/Validation/GeoLocationValidator.cs @@ -1,15 +1,10 @@ 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/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() 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..de0cb9c 100644 --- a/Core/Dto/Request/CreateSuggestedNameDto.cs +++ b/Core/Dto/Request/CreateSuggestedNameDto.cs @@ -1,15 +1,14 @@ - -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/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..beb674e 100644 --- a/Core/Dto/Request/NameDto.cs +++ b/Core/Dto/Request/NameDto.cs @@ -1,5 +1,4 @@ using Core.Enums; -using System.ComponentModel.DataAnnotations; namespace Core.Dto.Request { @@ -13,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; } @@ -33,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(); } } } 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/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.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/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/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/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/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 -

} } 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

+ } } 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..6bbaa85 100644 --- a/YorubaNameDictionary.sln +++ b/YorubaNameDictionary.sln @@ -24,8 +24,11 @@ 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 +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 @@ -60,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: