From 579e05b85335f3acab044a7dd2507b8da9f8ae7e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 26 Jan 2026 17:08:13 -0500 Subject: [PATCH 1/2] [FA-misc] Initial MassTransit implementation seems to work --- .../AuthenticationWebhookController.cs | 36 ----- .../Dockerfile | 23 --- ...chive.Service.AuthenticationService.csproj | 29 ---- ...Archive.Service.AuthenticationService.http | 6 - .../IntegrationEvents/AuthUserAddedEvent.cs | 16 -- .../Requests/UserRegisteredWebhookPayload.cs | 17 --- .../Program.cs | 49 ------- .../Properties/launchSettings.json | 41 ------ .../appsettings.Development.json | 8 - .../appsettings.json | 13 -- .../FileUploadRequestCreatedConsumer.cs | 74 ++++++++++ .../FileUploadRequestStatusUpdate.cs | 10 ++ .../FileUploadRequestCreatedEvent.cs | 10 -- .../FileUploadRequestStatusUpdateEvent.cs | 22 --- FictionArchive.Service.FileService/Program.cs | 19 ++- .../FileUploadRequestCreatedEventHandler.cs | 58 -------- .../appsettings.json | 3 +- .../NovelUpdateServiceTests.cs | 36 +++-- .../Consumers/ChapterPullRequestedConsumer.cs | 26 ++++ .../FileUploadRequestStatusUpdateConsumer.cs | 47 ++++++ .../Consumers/NovelUpdateRequestedConsumer.cs | 26 ++++ .../TranslationRequestCompletedConsumer.cs | 48 ++++++ .../Contracts/ChapterCreated.cs | 11 ++ .../Contracts/ChapterPullRequested.cs | 8 + .../Contracts/FileUploadRequestCreated.cs | 8 + .../Contracts/NovelCreated.cs | 11 ++ .../Contracts/NovelUpdateRequested.cs | 6 + .../Contracts/TranslationRequestCreated.cs | 11 ++ .../GraphQL/Mutation.cs | 7 +- .../IntegrationEvents/ChapterCreatedEvent.cs | 13 -- .../ChapterPullRequestedEvent.cs | 10 -- .../FileUploadRequestCreatedEvent.cs | 10 -- .../FileUploadRequestStatusUpdateEvent.cs | 22 --- .../IntegrationEvents/NovelCreatedEvent.cs | 13 -- .../NovelUpdateRequestedEvent.cs | 8 - .../TranslationRequestCompletedEvent.cs | 17 --- .../TranslationRequestCreatedEvent.cs | 13 -- .../Program.cs | 23 ++- .../ChapterPullRequestedEventHandler.cs | 19 --- ...leUploadRequestStatusUpdateEventHandler.cs | 39 ----- .../NovelUpdateRequestedEventHandler.cs | 23 --- ...TranslationRequestCompletedEventHandler.cs | 39 ----- .../Services/NovelUpdateService.cs | 95 +++++------- .../appsettings.json | 3 +- .../Models/JobTemplates/EventJobTemplate.cs | 14 +- .../Program.cs | 6 +- .../Services/JobManagerService.cs | 1 - .../appsettings.json | 3 +- .../Contracts/Events/IChapterCreated.cs | 11 ++ .../Contracts/Events/IChapterPullRequested.cs | 8 + .../Events/IFileUploadRequestCreated.cs | 8 + .../Events/IFileUploadRequestStatusUpdate.cs | 11 ++ .../Contracts/Events/INovelCreated.cs | 12 ++ .../Contracts/Events/INovelUpdateRequested.cs | 6 + .../Events/ITranslationRequestCompleted.cs | 7 + .../Events/ITranslationRequestCreated.cs | 12 ++ .../Contracts/Events/IUserInvited.cs | 12 ++ .../Extensions/MassTransitExtensions.cs | 118 +++++++++++++++ .../FictionArchive.Service.Shared.csproj | 2 +- .../Services/EventBus/EventBusBuilder.cs | 25 ---- .../Services/EventBus/EventBusExtensions.cs | 12 -- .../Services/EventBus/IEventBus.cs | 7 - .../Services/EventBus/IIntegrationEvent.cs | 7 - .../EventBus/IIntegrationEventHandler.cs | 12 -- .../RabbitMQConnectionProvider.cs | 35 ----- .../Implementations/RabbitMQEventBus.cs | 137 ------------------ .../Implementations/RabbitMQExtensions.cs | 24 --- .../Implementations/RabbitMQOptions.cs | 7 - .../Services/EventBus/SubscriptionManager.cs | 11 -- .../Services/Filters/LoggingConsumeFilter.cs | 33 +++++ .../TranslationRequestCreatedConsumer.cs | 53 +++++++ .../Contracts/TranslationRequestCompleted.cs | 7 + .../TranslationRequestCompletedEvent.cs | 18 --- .../TranslationRequestCreatedEvent.cs | 13 -- .../Program.cs | 18 +-- .../TranslationRequestCreatedEventHandler.cs | 31 ---- .../Services/TranslationEngineService.cs | 9 +- .../appsettings.json | 3 +- .../ChapterCreatedConsumer.cs} | 39 ++--- .../Consumers/NovelCreatedConsumer.cs | 39 +++++ .../Consumers/UserInvitedConsumer.cs | 44 ++++++ .../IntegrationEvents/ChapterCreatedEvent.cs | 13 -- .../IntegrationEvents/NovelCreatedEvent.cs | 13 -- .../IntegrationEvents/UserInvitedEvent.cs | 15 -- .../Program.cs | 21 ++- .../EventHandlers/NovelCreatedEventHandler.cs | 36 ----- .../EventHandlers/UserInvitedEventHandler.cs | 40 ----- .../appsettings.json | 3 +- .../UserManagementServiceTests.cs | 6 +- .../Contracts/UserInvited.cs | 12 ++ .../IntegrationEvents/UserInvitedEvent.cs | 17 --- FictionArchive.Service.UserService/Program.cs | 11 +- .../Services/UserManagementService.cs | 29 ++-- .../appsettings.json | 3 +- .../src/lib/graphql/__generated__/graphql.ts | 12 +- .../lib/graphql/mutations/importNovel.graphql | 2 +- 96 files changed, 845 insertions(+), 1229 deletions(-) delete mode 100644 FictionArchive.Service.AuthenticationService/Controllers/AuthenticationWebhookController.cs delete mode 100644 FictionArchive.Service.AuthenticationService/Dockerfile delete mode 100644 FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.csproj delete mode 100644 FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.http delete mode 100644 FictionArchive.Service.AuthenticationService/Models/IntegrationEvents/AuthUserAddedEvent.cs delete mode 100644 FictionArchive.Service.AuthenticationService/Models/Requests/UserRegisteredWebhookPayload.cs delete mode 100644 FictionArchive.Service.AuthenticationService/Program.cs delete mode 100644 FictionArchive.Service.AuthenticationService/Properties/launchSettings.json delete mode 100644 FictionArchive.Service.AuthenticationService/appsettings.Development.json delete mode 100644 FictionArchive.Service.AuthenticationService/appsettings.json create mode 100644 FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs create mode 100644 FictionArchive.Service.FileService/Contracts/FileUploadRequestStatusUpdate.cs delete mode 100644 FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs delete mode 100644 FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs delete mode 100644 FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs create mode 100644 FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs create mode 100644 FictionArchive.Service.NovelService/Consumers/FileUploadRequestStatusUpdateConsumer.cs create mode 100644 FictionArchive.Service.NovelService/Consumers/NovelUpdateRequestedConsumer.cs create mode 100644 FictionArchive.Service.NovelService/Consumers/TranslationRequestCompletedConsumer.cs create mode 100644 FictionArchive.Service.NovelService/Contracts/ChapterCreated.cs create mode 100644 FictionArchive.Service.NovelService/Contracts/ChapterPullRequested.cs create mode 100644 FictionArchive.Service.NovelService/Contracts/FileUploadRequestCreated.cs create mode 100644 FictionArchive.Service.NovelService/Contracts/NovelCreated.cs create mode 100644 FictionArchive.Service.NovelService/Contracts/NovelUpdateRequested.cs create mode 100644 FictionArchive.Service.NovelService/Contracts/TranslationRequestCreated.cs delete mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterCreatedEvent.cs delete mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterPullRequestedEvent.cs delete mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs delete mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs delete mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelCreatedEvent.cs delete mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelUpdateRequestedEvent.cs delete mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/TranslationRequestCompletedEvent.cs delete mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/TranslationRequestCreatedEvent.cs delete mode 100644 FictionArchive.Service.NovelService/Services/EventHandlers/ChapterPullRequestedEventHandler.cs delete mode 100644 FictionArchive.Service.NovelService/Services/EventHandlers/FileUploadRequestStatusUpdateEventHandler.cs delete mode 100644 FictionArchive.Service.NovelService/Services/EventHandlers/NovelUpdateRequestedEventHandler.cs delete mode 100644 FictionArchive.Service.NovelService/Services/EventHandlers/TranslationRequestCompletedEventHandler.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/IChapterCreated.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/IChapterPullRequested.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestCreated.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestStatusUpdate.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/INovelCreated.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/INovelUpdateRequested.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/ITranslationRequestCompleted.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/ITranslationRequestCreated.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/IUserInvited.cs create mode 100644 FictionArchive.Service.Shared/Extensions/MassTransitExtensions.cs delete mode 100644 FictionArchive.Service.Shared/Services/EventBus/EventBusBuilder.cs delete mode 100644 FictionArchive.Service.Shared/Services/EventBus/EventBusExtensions.cs delete mode 100644 FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs delete mode 100644 FictionArchive.Service.Shared/Services/EventBus/IIntegrationEvent.cs delete mode 100644 FictionArchive.Service.Shared/Services/EventBus/IIntegrationEventHandler.cs delete mode 100644 FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQConnectionProvider.cs delete mode 100644 FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs delete mode 100644 FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQExtensions.cs delete mode 100644 FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQOptions.cs delete mode 100644 FictionArchive.Service.Shared/Services/EventBus/SubscriptionManager.cs create mode 100644 FictionArchive.Service.Shared/Services/Filters/LoggingConsumeFilter.cs create mode 100644 FictionArchive.Service.TranslationService/Consumers/TranslationRequestCreatedConsumer.cs create mode 100644 FictionArchive.Service.TranslationService/Contracts/TranslationRequestCompleted.cs delete mode 100644 FictionArchive.Service.TranslationService/Models/IntegrationEvents/TranslationRequestCompletedEvent.cs delete mode 100644 FictionArchive.Service.TranslationService/Models/IntegrationEvents/TranslationRequestCreatedEvent.cs delete mode 100644 FictionArchive.Service.TranslationService/Services/EventHandlers/TranslationRequestCreatedEventHandler.cs rename FictionArchive.Service.UserNovelDataService/{Services/EventHandlers/ChapterCreatedEventHandler.cs => Consumers/ChapterCreatedConsumer.cs} (52%) create mode 100644 FictionArchive.Service.UserNovelDataService/Consumers/NovelCreatedConsumer.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Consumers/UserInvitedConsumer.cs delete mode 100644 FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/ChapterCreatedEvent.cs delete mode 100644 FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/NovelCreatedEvent.cs delete mode 100644 FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/UserInvitedEvent.cs delete mode 100644 FictionArchive.Service.UserNovelDataService/Services/EventHandlers/NovelCreatedEventHandler.cs delete mode 100644 FictionArchive.Service.UserNovelDataService/Services/EventHandlers/UserInvitedEventHandler.cs create mode 100644 FictionArchive.Service.UserService/Contracts/UserInvited.cs delete mode 100644 FictionArchive.Service.UserService/Models/IntegrationEvents/UserInvitedEvent.cs diff --git a/FictionArchive.Service.AuthenticationService/Controllers/AuthenticationWebhookController.cs b/FictionArchive.Service.AuthenticationService/Controllers/AuthenticationWebhookController.cs deleted file mode 100644 index 7ab6272..0000000 --- a/FictionArchive.Service.AuthenticationService/Controllers/AuthenticationWebhookController.cs +++ /dev/null @@ -1,36 +0,0 @@ -using FictionArchive.Service.AuthenticationService.Models.Requests; -using FictionArchive.Service.AuthenticationService.Models.IntegrationEvents; -using FictionArchive.Service.Shared.Services.EventBus; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace FictionArchive.Service.AuthenticationService.Controllers -{ - [Route("api/[controller]")] - [ApiController] - public class AuthenticationWebhookController : ControllerBase - { - private readonly IEventBus _eventBus; - - public AuthenticationWebhookController(IEventBus eventBus) - { - _eventBus = eventBus; - } - - [HttpPost(nameof(UserRegistered))] - public async Task UserRegistered([FromBody] UserRegisteredWebhookPayload payload) - { - var authUserAddedEvent = new AuthUserAddedEvent - { - OAuthProviderId = payload.OAuthProviderId, - InviterOAuthProviderId = payload.InviterOAuthProviderId, - EventUserEmail = payload.EventUserEmail, - EventUserUsername = payload.EventUserUsername - }; - - await _eventBus.Publish(authUserAddedEvent); - - return Ok(); - } - } -} \ No newline at end of file diff --git a/FictionArchive.Service.AuthenticationService/Dockerfile b/FictionArchive.Service.AuthenticationService/Dockerfile deleted file mode 100644 index 5907b71..0000000 --- a/FictionArchive.Service.AuthenticationService/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base -USER $APP_UID -WORKDIR /app -EXPOSE 8080 -EXPOSE 8081 - -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -ARG BUILD_CONFIGURATION=Release -WORKDIR /src -COPY ["FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.csproj", "FictionArchive.Service.AuthenticationService/"] -RUN dotnet restore "FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.csproj" -COPY . . -WORKDIR "/src/FictionArchive.Service.AuthenticationService" -RUN dotnet build "./FictionArchive.Service.AuthenticationService.csproj" -c $BUILD_CONFIGURATION -o /app/build - -FROM build AS publish -ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "./FictionArchive.Service.AuthenticationService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "FictionArchive.Service.AuthenticationService.dll"] diff --git a/FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.csproj b/FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.csproj deleted file mode 100644 index 6ac3804..0000000 --- a/FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net8.0 - enable - enable - Linux - - - - - - - - - - .dockerignore - - - - - - - - - - - - diff --git a/FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.http b/FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.http deleted file mode 100644 index 8668e05..0000000 --- a/FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.http +++ /dev/null @@ -1,6 +0,0 @@ -@FictionArchive.Service.AuthenticationService_HostAddress = http://localhost:5091 - -GET {{FictionArchive.Service.AuthenticationService_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/FictionArchive.Service.AuthenticationService/Models/IntegrationEvents/AuthUserAddedEvent.cs b/FictionArchive.Service.AuthenticationService/Models/IntegrationEvents/AuthUserAddedEvent.cs deleted file mode 100644 index 93ace36..0000000 --- a/FictionArchive.Service.AuthenticationService/Models/IntegrationEvents/AuthUserAddedEvent.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.AuthenticationService.Models.IntegrationEvents; - -public class AuthUserAddedEvent : IIntegrationEvent -{ - public string OAuthProviderId { get; set; } - - public string InviterOAuthProviderId { get; set; } - - // The email of the user that created the event - public string EventUserEmail { get; set; } - - // The username of the user that created the event - public string EventUserUsername { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.AuthenticationService/Models/Requests/UserRegisteredWebhookPayload.cs b/FictionArchive.Service.AuthenticationService/Models/Requests/UserRegisteredWebhookPayload.cs deleted file mode 100644 index fffcb1c..0000000 --- a/FictionArchive.Service.AuthenticationService/Models/Requests/UserRegisteredWebhookPayload.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace FictionArchive.Service.AuthenticationService.Models.Requests; - -public class UserRegisteredWebhookPayload -{ - // The body of the notification message - public string Body { get; set; } - - public string OAuthProviderId { get; set; } - - public string InviterOAuthProviderId { get; set; } - - // The email of the user that created the event - public string EventUserEmail { get; set; } - - // The username of the user that created the event - public string EventUserUsername { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.AuthenticationService/Program.cs b/FictionArchive.Service.AuthenticationService/Program.cs deleted file mode 100644 index 897d20e..0000000 --- a/FictionArchive.Service.AuthenticationService/Program.cs +++ /dev/null @@ -1,49 +0,0 @@ -using FictionArchive.Service.Shared; -using FictionArchive.Service.Shared.Services.EventBus.Implementations; - -namespace FictionArchive.Service.AuthenticationService; - -public class Program -{ - public static void Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - // Add services to the container. - - builder.Services.AddControllers(); - // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(); - - #region Event Bus - - builder.Services.AddRabbitMQ(opt => - { - builder.Configuration.GetSection("RabbitMQ").Bind(opt); - }); - - #endregion - - builder.Services.AddHealthChecks(); - - var app = builder.Build(); - - // Configure the HTTP request pipeline. - if (app.Environment.IsDevelopment()) - { - app.UseSwagger(); - app.UseSwaggerUI(); - } - - app.UseHttpsRedirection(); - - app.MapHealthChecks("/healthz"); - - app.UseAuthorization(); - - app.MapControllers(); - - app.Run(); - } -} \ No newline at end of file diff --git a/FictionArchive.Service.AuthenticationService/Properties/launchSettings.json b/FictionArchive.Service.AuthenticationService/Properties/launchSettings.json deleted file mode 100644 index c6a404f..0000000 --- a/FictionArchive.Service.AuthenticationService/Properties/launchSettings.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:23522", - "sslPort": 44397 - } - }, - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5091", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7223;http://localhost:5091", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/FictionArchive.Service.AuthenticationService/appsettings.Development.json b/FictionArchive.Service.AuthenticationService/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/FictionArchive.Service.AuthenticationService/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/FictionArchive.Service.AuthenticationService/appsettings.json b/FictionArchive.Service.AuthenticationService/appsettings.json deleted file mode 100644 index 9087a1b..0000000 --- a/FictionArchive.Service.AuthenticationService/appsettings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "RabbitMQ": { - "ConnectionString": "amqp://localhost", - "ClientIdentifier": "AuthenticationService" - }, - "AllowedHosts": "*" -} diff --git a/FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs b/FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs new file mode 100644 index 0000000..0825358 --- /dev/null +++ b/FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs @@ -0,0 +1,74 @@ +using Amazon.S3; +using Amazon.S3.Model; +using FictionArchive.Common.Enums; +using FictionArchive.Service.FileService.Contracts; +using FictionArchive.Service.FileService.Models; +using FictionArchive.Service.Shared.Contracts.Events; +using MassTransit; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FictionArchive.Service.FileService.Consumers; + +public class FileUploadRequestCreatedConsumer : IConsumer +{ + private readonly ILogger _logger; + private readonly AmazonS3Client _amazonS3Client; + private readonly IPublishEndpoint _publishEndpoint; + private readonly S3Configuration _s3Configuration; + private readonly ProxyConfiguration _proxyConfiguration; + + public FileUploadRequestCreatedConsumer( + ILogger logger, + AmazonS3Client amazonS3Client, + IPublishEndpoint publishEndpoint, + IOptions s3Configuration, + IOptions proxyConfiguration) + { + _logger = logger; + _amazonS3Client = amazonS3Client; + _publishEndpoint = publishEndpoint; + _s3Configuration = s3Configuration.Value; + _proxyConfiguration = proxyConfiguration.Value; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + var putObjectRequest = new PutObjectRequest + { + BucketName = _s3Configuration.Bucket, + Key = message.FilePath + }; + + using var memoryStream = new MemoryStream(message.FileData); + putObjectRequest.InputStream = memoryStream; + + var s3Response = await _amazonS3Client.PutObjectAsync(putObjectRequest); + + if (s3Response.HttpStatusCode != System.Net.HttpStatusCode.OK) + { + _logger.LogError("Failed to upload file {FilePath} to S3", message.FilePath); + + await _publishEndpoint.Publish( + new FileUploadRequestStatusUpdate( + RequestId: message.RequestId, + Status: RequestStatus.Failed, + FileAccessUrl: null, + ErrorMessage: "An error occurred while uploading file to S3.")); + return; + } + + var fileAccessUrl = _proxyConfiguration.BaseUrl + "/" + message.FilePath; + + _logger.LogInformation("Successfully uploaded file {FilePath} to S3", message.FilePath); + + await _publishEndpoint.Publish( + new FileUploadRequestStatusUpdate( + RequestId: message.RequestId, + Status: RequestStatus.Success, + FileAccessUrl: fileAccessUrl, + ErrorMessage: null)); + } +} diff --git a/FictionArchive.Service.FileService/Contracts/FileUploadRequestStatusUpdate.cs b/FictionArchive.Service.FileService/Contracts/FileUploadRequestStatusUpdate.cs new file mode 100644 index 0000000..dcb1eef --- /dev/null +++ b/FictionArchive.Service.FileService/Contracts/FileUploadRequestStatusUpdate.cs @@ -0,0 +1,10 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.Shared.Contracts.Events; + +namespace FictionArchive.Service.FileService.Contracts; + +public record FileUploadRequestStatusUpdate( + Guid RequestId, + RequestStatus Status, + string? FileAccessUrl, + string? ErrorMessage) : IFileUploadRequestStatusUpdate; diff --git a/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs b/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs deleted file mode 100644 index bf03a56..0000000 --- a/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs +++ /dev/null @@ -1,10 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.FileService.Models.IntegrationEvents; - -public class FileUploadRequestCreatedEvent : IIntegrationEvent -{ - public Guid RequestId { get; set; } - public string FilePath { get; set; } - public byte[] FileData { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs b/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs deleted file mode 100644 index b5936de..0000000 --- a/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FictionArchive.Common.Enums; -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.FileService.Models.IntegrationEvents; - -public class FileUploadRequestStatusUpdateEvent : IIntegrationEvent -{ - public Guid RequestId { get; set; } - public RequestStatus Status { get; set; } - - #region Success - - public string? FileAccessUrl { get; set; } - - #endregion - - #region Failure - - public string? ErrorMessage { get; set; } - - #endregion -} \ No newline at end of file diff --git a/FictionArchive.Service.FileService/Program.cs b/FictionArchive.Service.FileService/Program.cs index 7d8ac6d..da6a8b8 100644 --- a/FictionArchive.Service.FileService/Program.cs +++ b/FictionArchive.Service.FileService/Program.cs @@ -1,11 +1,9 @@ using Amazon.Runtime; using Amazon.S3; using FictionArchive.Common.Extensions; +using FictionArchive.Service.FileService.Consumers; using FictionArchive.Service.FileService.Models; -using FictionArchive.Service.FileService.Models.IntegrationEvents; -using FictionArchive.Service.FileService.Services.EventHandlers; using FictionArchive.Service.Shared.Extensions; -using FictionArchive.Service.Shared.Services.EventBus.Implementations; using Microsoft.Extensions.Options; namespace FictionArchive.Service.FileService; @@ -24,14 +22,15 @@ public class Program builder.Services.AddHealthChecks(); - #region Event Bus + #region MassTransit + + builder.Services.AddFictionArchiveMassTransit( + builder.Configuration, + x => + { + x.AddConsumer(); + }); - builder.Services.AddRabbitMQ(opt => - { - builder.Configuration.GetSection("RabbitMQ").Bind(opt); - }) - .Subscribe(); - #endregion // Add authentication with cookie support diff --git a/FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs b/FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs deleted file mode 100644 index 0383030..0000000 --- a/FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Amazon.S3; -using Amazon.S3.Model; -using FictionArchive.Common.Enums; -using FictionArchive.Service.FileService.Models; -using FictionArchive.Service.FileService.Models.IntegrationEvents; -using FictionArchive.Service.Shared.Services.EventBus; -using Microsoft.Extensions.Options; - -namespace FictionArchive.Service.FileService.Services.EventHandlers; - -public class FileUploadRequestCreatedEventHandler : IIntegrationEventHandler -{ - private readonly ILogger _logger; - private readonly AmazonS3Client _amazonS3Client; - private readonly IEventBus _eventBus; - private readonly S3Configuration _s3Configuration; - private readonly ProxyConfiguration _proxyConfiguration; - - public FileUploadRequestCreatedEventHandler(ILogger logger, AmazonS3Client amazonS3Client, IEventBus eventBus, IOptions s3Configuration, IOptions proxyConfiguration) - { - _logger = logger; - _amazonS3Client = amazonS3Client; - _eventBus = eventBus; - _proxyConfiguration = proxyConfiguration.Value; - _s3Configuration = s3Configuration.Value; - } - - public async Task Handle(FileUploadRequestCreatedEvent @event) - { - var putObjectRequest = new PutObjectRequest(); - putObjectRequest.BucketName = _s3Configuration.Bucket; - putObjectRequest.Key = @event.FilePath; - putObjectRequest.UseChunkEncoding = false; // Needed to avoid an error with Garage - - using MemoryStream memoryStream = new MemoryStream(@event.FileData); - putObjectRequest.InputStream = memoryStream; - - var s3Response = await _amazonS3Client.PutObjectAsync(putObjectRequest); - if (s3Response.HttpStatusCode != System.Net.HttpStatusCode.OK) - { - _logger.LogError("An error occurred while uploading file to S3. Response code: {responsecode}", s3Response.HttpStatusCode); - await _eventBus.Publish(new FileUploadRequestStatusUpdateEvent() - { - RequestId = @event.RequestId, - Status = RequestStatus.Failed, - ErrorMessage = "An error occurred while uploading file to S3." - }); - return; - } - - await _eventBus.Publish(new FileUploadRequestStatusUpdateEvent() - { - Status = RequestStatus.Success, - RequestId = @event.RequestId, - FileAccessUrl = _proxyConfiguration.BaseUrl + "/" + @event.FilePath - }); - } -} \ No newline at end of file diff --git a/FictionArchive.Service.FileService/appsettings.json b/FictionArchive.Service.FileService/appsettings.json index 0ec37b4..87fabdd 100644 --- a/FictionArchive.Service.FileService/appsettings.json +++ b/FictionArchive.Service.FileService/appsettings.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" } }, "ProxyConfiguration": { diff --git a/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs index d5d2790..903d450 100644 --- a/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs +++ b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs @@ -1,5 +1,4 @@ using FictionArchive.Common.Enums; -using FictionArchive.Service.FileService.IntegrationEvents; using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.Enums; using FictionArchive.Service.NovelService.Models.Images; @@ -8,9 +7,10 @@ using FictionArchive.Service.NovelService.Models.Novels; using FictionArchive.Service.NovelService.Models.SourceAdapters; using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services.SourceAdapters; -using FictionArchive.Service.Shared.Services.EventBus; +using FictionArchive.Service.Shared.Contracts.Events; using FluentAssertions; using HtmlAgilityPack; +using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -72,7 +72,7 @@ public class NovelUpdateServiceTests private static NovelUpdateService CreateService( NovelServiceDbContext dbContext, ISourceAdapter adapter, - IEventBus eventBus, + IPublishEndpoint publishEndpoint, string pendingImageUrl = "https://pending/placeholder.jpg") { var options = Options.Create(new NovelUpdateServiceConfiguration @@ -80,7 +80,7 @@ public class NovelUpdateServiceTests PendingImageUrl = pendingImageUrl }); - return new NovelUpdateService(dbContext, NullLogger.Instance, new[] { adapter }, eventBus, options); + return new NovelUpdateService(dbContext, NullLogger.Instance, new[] { adapter }, publishEndpoint, options); } [Fact] @@ -102,13 +102,13 @@ public class NovelUpdateServiceTests ImageData = new List { image1, image2 } })); - var publishedEvents = new List(); - var eventBus = Substitute.For(); - eventBus.Publish(Arg.Do(publishedEvents.Add)).Returns(Task.CompletedTask); - eventBus.Publish(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + var publishedEvents = new List(); + var publishEndpoint = Substitute.For(); + publishEndpoint.Publish(Arg.Do(e => publishedEvents.Add(e)), Arg.Any()) + .Returns(Task.CompletedTask); var pendingImageUrl = "https://pending/placeholder.jpg"; - var service = CreateService(dbContext, adapter, eventBus, pendingImageUrl); + var service = CreateService(dbContext, adapter, publishEndpoint, pendingImageUrl); var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order); @@ -151,11 +151,9 @@ public class NovelUpdateServiceTests ImageData = new List { image } })); - var eventBus = Substitute.For(); - eventBus.Publish(Arg.Any()).Returns(Task.CompletedTask); - eventBus.Publish(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + var publishEndpoint = Substitute.For(); - var service = CreateService(dbContext, adapter, eventBus); + var service = CreateService(dbContext, adapter, publishEndpoint); var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order); @@ -186,8 +184,8 @@ public class NovelUpdateServiceTests await dbContext.SaveChangesAsync(); var adapter = Substitute.For(); - var eventBus = Substitute.For(); - var service = CreateService(dbContext, adapter, eventBus); + var publishEndpoint = Substitute.For(); + var service = CreateService(dbContext, adapter, publishEndpoint); var newUrl = "https://cdn.example.com/uploaded/cover.jpg"; @@ -228,8 +226,8 @@ public class NovelUpdateServiceTests await dbContext.SaveChangesAsync(); var adapter = Substitute.For(); - var eventBus = Substitute.For(); - var service = CreateService(dbContext, adapter, eventBus, pendingUrl); + var publishEndpoint = Substitute.For(); + var service = CreateService(dbContext, adapter, publishEndpoint, pendingUrl); var newUrl = "https://cdn.example.com/uploaded/image.jpg"; @@ -277,8 +275,8 @@ public class NovelUpdateServiceTests await dbContext.SaveChangesAsync(); var adapter = Substitute.For(); - var eventBus = Substitute.For(); - var service = CreateService(dbContext, adapter, eventBus, pendingUrl); + var publishEndpoint = Substitute.For(); + var service = CreateService(dbContext, adapter, publishEndpoint, pendingUrl); var newUrl = "https://cdn.example.com/uploaded/img1.jpg"; diff --git a/FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs b/FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs new file mode 100644 index 0000000..117124d --- /dev/null +++ b/FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs @@ -0,0 +1,26 @@ +using FictionArchive.Service.NovelService.Services; +using FictionArchive.Service.Shared.Contracts.Events; +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace FictionArchive.Service.NovelService.Consumers; + +public class ChapterPullRequestedConsumer : IConsumer +{ + private readonly ILogger _logger; + private readonly NovelUpdateService _novelUpdateService; + + public ChapterPullRequestedConsumer( + ILogger logger, + NovelUpdateService novelUpdateService) + { + _logger = logger; + _novelUpdateService = novelUpdateService; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + await _novelUpdateService.PullChapterContents(message.NovelId, message.VolumeId, message.ChapterOrder); + } +} diff --git a/FictionArchive.Service.NovelService/Consumers/FileUploadRequestStatusUpdateConsumer.cs b/FictionArchive.Service.NovelService/Consumers/FileUploadRequestStatusUpdateConsumer.cs new file mode 100644 index 0000000..c25b3f5 --- /dev/null +++ b/FictionArchive.Service.NovelService/Consumers/FileUploadRequestStatusUpdateConsumer.cs @@ -0,0 +1,47 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.NovelService.Services; +using FictionArchive.Service.Shared.Contracts.Events; +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace FictionArchive.Service.NovelService.Consumers; + +public class FileUploadRequestStatusUpdateConsumer : IConsumer +{ + private readonly ILogger _logger; + private readonly NovelServiceDbContext _dbContext; + private readonly NovelUpdateService _novelUpdateService; + + public FileUploadRequestStatusUpdateConsumer( + ILogger logger, + NovelServiceDbContext dbContext, + NovelUpdateService novelUpdateService) + { + _logger = logger; + _dbContext = dbContext; + _novelUpdateService = novelUpdateService; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + var image = await _dbContext.Images.FindAsync(message.RequestId); + if (image == null) + { + // Not a request we care about. + return; + } + + if (message.Status == RequestStatus.Failed) + { + _logger.LogError("Image upload failed for image with id {imageId}", image.Id); + return; + } + else if (message.Status == RequestStatus.Success) + { + _logger.LogInformation("Image upload succeeded for image with id {imageId}", image.Id); + await _novelUpdateService.UpdateImage(image.Id, message.FileAccessUrl); + } + } +} diff --git a/FictionArchive.Service.NovelService/Consumers/NovelUpdateRequestedConsumer.cs b/FictionArchive.Service.NovelService/Consumers/NovelUpdateRequestedConsumer.cs new file mode 100644 index 0000000..d09ccec --- /dev/null +++ b/FictionArchive.Service.NovelService/Consumers/NovelUpdateRequestedConsumer.cs @@ -0,0 +1,26 @@ +using FictionArchive.Service.NovelService.Services; +using FictionArchive.Service.Shared.Contracts.Events; +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace FictionArchive.Service.NovelService.Consumers; + +public class NovelUpdateRequestedConsumer : IConsumer +{ + private readonly ILogger _logger; + private readonly NovelUpdateService _novelUpdateService; + + public NovelUpdateRequestedConsumer( + ILogger logger, + NovelUpdateService novelUpdateService) + { + _logger = logger; + _novelUpdateService = novelUpdateService; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + await _novelUpdateService.ImportNovel(message.NovelUrl); + } +} diff --git a/FictionArchive.Service.NovelService/Consumers/TranslationRequestCompletedConsumer.cs b/FictionArchive.Service.NovelService/Consumers/TranslationRequestCompletedConsumer.cs new file mode 100644 index 0000000..af58abf --- /dev/null +++ b/FictionArchive.Service.NovelService/Consumers/TranslationRequestCompletedConsumer.cs @@ -0,0 +1,48 @@ +using FictionArchive.Service.NovelService.Models.Localization; +using FictionArchive.Service.NovelService.Services; +using FictionArchive.Service.Shared.Contracts.Events; +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace FictionArchive.Service.NovelService.Consumers; + +public class TranslationRequestCompletedConsumer : IConsumer +{ + private readonly ILogger _logger; + private readonly NovelServiceDbContext _dbContext; + + public TranslationRequestCompletedConsumer( + ILogger logger, + NovelServiceDbContext dbContext) + { + _logger = logger; + _dbContext = dbContext; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + var localizationRequest = await _dbContext.LocalizationRequests + .Include(r => r.KeyRequestedForTranslation) + .ThenInclude(lk => lk.Texts) + .FirstOrDefaultAsync(lk => lk.Id == message.TranslationRequestId); + + if (localizationRequest == null) + { + // Not one of our requests, discard it + return; + } + + localizationRequest.KeyRequestedForTranslation.Texts.Add(new LocalizationText + { + Language = localizationRequest.TranslateTo, + Text = message.TranslatedText, + TranslationEngine = localizationRequest.Engine + }); + + _dbContext.LocalizationRequests.Remove(localizationRequest); + await _dbContext.SaveChangesAsync(); + } +} diff --git a/FictionArchive.Service.NovelService/Contracts/ChapterCreated.cs b/FictionArchive.Service.NovelService/Contracts/ChapterCreated.cs new file mode 100644 index 0000000..6b413cb --- /dev/null +++ b/FictionArchive.Service.NovelService/Contracts/ChapterCreated.cs @@ -0,0 +1,11 @@ +using FictionArchive.Service.Shared.Contracts.Events; + +namespace FictionArchive.Service.NovelService.Contracts; + +public record ChapterCreated( + uint ChapterId, + uint NovelId, + uint VolumeId, + uint VolumeOrder, + uint ChapterOrder, + string ChapterTitle) : IChapterCreated; diff --git a/FictionArchive.Service.NovelService/Contracts/ChapterPullRequested.cs b/FictionArchive.Service.NovelService/Contracts/ChapterPullRequested.cs new file mode 100644 index 0000000..994975e --- /dev/null +++ b/FictionArchive.Service.NovelService/Contracts/ChapterPullRequested.cs @@ -0,0 +1,8 @@ +using FictionArchive.Service.Shared.Contracts.Events; + +namespace FictionArchive.Service.NovelService.Contracts; + +public record ChapterPullRequested( + uint NovelId, + uint VolumeId, + uint ChapterOrder) : IChapterPullRequested; diff --git a/FictionArchive.Service.NovelService/Contracts/FileUploadRequestCreated.cs b/FictionArchive.Service.NovelService/Contracts/FileUploadRequestCreated.cs new file mode 100644 index 0000000..b1ecdb2 --- /dev/null +++ b/FictionArchive.Service.NovelService/Contracts/FileUploadRequestCreated.cs @@ -0,0 +1,8 @@ +using FictionArchive.Service.Shared.Contracts.Events; + +namespace FictionArchive.Service.NovelService.Contracts; + +public record FileUploadRequestCreated( + Guid RequestId, + string FilePath, + byte[] FileData) : IFileUploadRequestCreated; diff --git a/FictionArchive.Service.NovelService/Contracts/NovelCreated.cs b/FictionArchive.Service.NovelService/Contracts/NovelCreated.cs new file mode 100644 index 0000000..bf35be2 --- /dev/null +++ b/FictionArchive.Service.NovelService/Contracts/NovelCreated.cs @@ -0,0 +1,11 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.Shared.Contracts.Events; + +namespace FictionArchive.Service.NovelService.Contracts; + +public record NovelCreated( + uint NovelId, + string Title, + Language OriginalLanguage, + string Source, + string AuthorName) : INovelCreated; diff --git a/FictionArchive.Service.NovelService/Contracts/NovelUpdateRequested.cs b/FictionArchive.Service.NovelService/Contracts/NovelUpdateRequested.cs new file mode 100644 index 0000000..2fe8887 --- /dev/null +++ b/FictionArchive.Service.NovelService/Contracts/NovelUpdateRequested.cs @@ -0,0 +1,6 @@ +using FictionArchive.Service.Shared.Contracts.Events; + +namespace FictionArchive.Service.NovelService.Contracts; + +public record NovelUpdateRequested( + string NovelUrl) : INovelUpdateRequested; diff --git a/FictionArchive.Service.NovelService/Contracts/TranslationRequestCreated.cs b/FictionArchive.Service.NovelService/Contracts/TranslationRequestCreated.cs new file mode 100644 index 0000000..ce29172 --- /dev/null +++ b/FictionArchive.Service.NovelService/Contracts/TranslationRequestCreated.cs @@ -0,0 +1,11 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.Shared.Contracts.Events; + +namespace FictionArchive.Service.NovelService.Contracts; + +public record TranslationRequestCreated( + Guid TranslationRequestId, + Language From, + Language To, + string Body, + string TranslationEngineKey) : ITranslationRequestCreated; diff --git a/FictionArchive.Service.NovelService/GraphQL/Mutation.cs b/FictionArchive.Service.NovelService/GraphQL/Mutation.cs index 47c3e40..d5163b4 100644 --- a/FictionArchive.Service.NovelService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.NovelService/GraphQL/Mutation.cs @@ -1,11 +1,10 @@ +using FictionArchive.Service.NovelService.Contracts; using FictionArchive.Service.NovelService.Models.Enums; -using FictionArchive.Service.NovelService.Models.IntegrationEvents; using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.NovelService.Models.Novels; using FictionArchive.Service.NovelService.Models.SourceAdapters; using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services.SourceAdapters; -using FictionArchive.Service.Shared.Services.EventBus; using HotChocolate.Authorization; using HotChocolate.Types; using Microsoft.EntityFrameworkCore; @@ -15,13 +14,13 @@ namespace FictionArchive.Service.NovelService.GraphQL; public class Mutation { [Authorize] - public async Task ImportNovel(string novelUrl, NovelUpdateService service) + public async Task ImportNovel(string novelUrl, NovelUpdateService service) { return await service.QueueNovelImport(novelUrl); } [Authorize] - public async Task FetchChapterContents( + public async Task FetchChapterContents( uint novelId, uint volumeId, uint chapterOrder, diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterCreatedEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterCreatedEvent.cs deleted file mode 100644 index 3608c2f..0000000 --- a/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterCreatedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; - -public class ChapterCreatedEvent : IIntegrationEvent -{ - public required uint ChapterId { get; init; } - public required uint NovelId { get; init; } - public required uint VolumeId { get; init; } - public required int VolumeOrder { get; init; } - public required uint ChapterOrder { get; init; } - public required string ChapterTitle { get; init; } -} diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterPullRequestedEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterPullRequestedEvent.cs deleted file mode 100644 index cd70549..0000000 --- a/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterPullRequestedEvent.cs +++ /dev/null @@ -1,10 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; - -public class ChapterPullRequestedEvent : IIntegrationEvent -{ - public uint NovelId { get; set; } - public uint VolumeId { get; set; } - public uint ChapterOrder { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs deleted file mode 100644 index 44e3f46..0000000 --- a/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs +++ /dev/null @@ -1,10 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.FileService.IntegrationEvents; - -public class FileUploadRequestCreatedEvent : IIntegrationEvent -{ - public Guid RequestId { get; set; } - public string FilePath { get; set; } - public byte[] FileData { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs deleted file mode 100644 index 4f61613..0000000 --- a/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FictionArchive.Common.Enums; -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; - -public class FileUploadRequestStatusUpdateEvent : IIntegrationEvent -{ - public Guid RequestId { get; set; } - public RequestStatus Status { get; set; } - - #region Success - - public string? FileAccessUrl { get; set; } - - #endregion - - #region Failure - - public string? ErrorMessage { get; set; } - - #endregion -} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelCreatedEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelCreatedEvent.cs deleted file mode 100644 index 50ede95..0000000 --- a/FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelCreatedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FictionArchive.Common.Enums; -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; - -public class NovelCreatedEvent : IIntegrationEvent -{ - public required uint NovelId { get; init; } - public required string Title { get; init; } - public required Language OriginalLanguage { get; init; } - public required string Source { get; init; } - public required string AuthorName { get; init; } -} diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelUpdateRequestedEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelUpdateRequestedEvent.cs deleted file mode 100644 index 5243a3f..0000000 --- a/FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelUpdateRequestedEvent.cs +++ /dev/null @@ -1,8 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; - -public class NovelUpdateRequestedEvent : IIntegrationEvent -{ - public string NovelUrl { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/TranslationRequestCompletedEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/TranslationRequestCompletedEvent.cs deleted file mode 100644 index 98d5ee6..0000000 --- a/FictionArchive.Service.NovelService/Models/IntegrationEvents/TranslationRequestCompletedEvent.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FictionArchive.Common.Enums; -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; - -public class TranslationRequestCompletedEvent : IIntegrationEvent -{ - /// - /// Maps this event back to a triggering request. - /// - public Guid? TranslationRequestId { get; set; } - - /// - /// The resulting text. - /// - public string? TranslatedText { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/TranslationRequestCreatedEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/TranslationRequestCreatedEvent.cs deleted file mode 100644 index e6623f0..0000000 --- a/FictionArchive.Service.NovelService/Models/IntegrationEvents/TranslationRequestCreatedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FictionArchive.Common.Enums; -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; - -public class TranslationRequestCreatedEvent : IIntegrationEvent -{ - public Guid TranslationRequestId { get; set; } - public Language From { get; set; } - public Language To { get; set; } - public string Body { get; set; } - public string TranslationEngineKey { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Program.cs b/FictionArchive.Service.NovelService/Program.cs index e06f9b9..95af51f 100644 --- a/FictionArchive.Service.NovelService/Program.cs +++ b/FictionArchive.Service.NovelService/Program.cs @@ -1,14 +1,12 @@ using FictionArchive.Common.Extensions; +using FictionArchive.Service.NovelService.Consumers; using FictionArchive.Service.NovelService.GraphQL; using FictionArchive.Service.NovelService.Models.Configuration; -using FictionArchive.Service.NovelService.Models.IntegrationEvents; using FictionArchive.Service.NovelService.Services; -using FictionArchive.Service.NovelService.Services.EventHandlers; using FictionArchive.Service.NovelService.Services.SourceAdapters; using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia; using FictionArchive.Service.Shared; using FictionArchive.Service.Shared.Extensions; -using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.Shared.Services.GraphQL; using Microsoft.EntityFrameworkCore; @@ -25,18 +23,19 @@ public class Program builder.Services.AddMemoryCache(); - #region Event Bus + #region MassTransit if (!isSchemaExport) { - builder.Services.AddRabbitMQ(opt => - { - builder.Configuration.GetSection("RabbitMQ").Bind(opt); - }) - .Subscribe() - .Subscribe() - .Subscribe() - .Subscribe(); + builder.Services.AddFictionArchiveMassTransit( + builder.Configuration, + x => + { + x.AddConsumer(); + x.AddConsumer(); + x.AddConsumer(); + x.AddConsumer(); + }); } #endregion diff --git a/FictionArchive.Service.NovelService/Services/EventHandlers/ChapterPullRequestedEventHandler.cs b/FictionArchive.Service.NovelService/Services/EventHandlers/ChapterPullRequestedEventHandler.cs deleted file mode 100644 index 6acd8ee..0000000 --- a/FictionArchive.Service.NovelService/Services/EventHandlers/ChapterPullRequestedEventHandler.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FictionArchive.Service.NovelService.Models.IntegrationEvents; -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.NovelService.Services.EventHandlers; - -public class ChapterPullRequestedEventHandler : IIntegrationEventHandler -{ - private readonly NovelUpdateService _novelUpdateService; - - public ChapterPullRequestedEventHandler(NovelUpdateService novelUpdateService) - { - _novelUpdateService = novelUpdateService; - } - - public async Task Handle(ChapterPullRequestedEvent @event) - { - await _novelUpdateService.PullChapterContents(@event.NovelId, @event.VolumeId, @event.ChapterOrder); - } -} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Services/EventHandlers/FileUploadRequestStatusUpdateEventHandler.cs b/FictionArchive.Service.NovelService/Services/EventHandlers/FileUploadRequestStatusUpdateEventHandler.cs deleted file mode 100644 index f6b3dc3..0000000 --- a/FictionArchive.Service.NovelService/Services/EventHandlers/FileUploadRequestStatusUpdateEventHandler.cs +++ /dev/null @@ -1,39 +0,0 @@ -using FictionArchive.Common.Enums; -using FictionArchive.Service.NovelService.Models.IntegrationEvents; -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.NovelService.Services.EventHandlers; - -public class FileUploadRequestStatusUpdateEventHandler : IIntegrationEventHandler -{ - private readonly ILogger _logger; - private readonly NovelServiceDbContext _context; - private readonly NovelUpdateService _novelUpdateService; - - public FileUploadRequestStatusUpdateEventHandler(ILogger logger, NovelServiceDbContext context, NovelUpdateService novelUpdateService) - { - _logger = logger; - _context = context; - _novelUpdateService = novelUpdateService; - } - - public async Task Handle(FileUploadRequestStatusUpdateEvent @event) - { - var image = await _context.Images.FindAsync(@event.RequestId); - if (image == null) - { - // Not a request we care about. - return; - } - if (@event.Status == RequestStatus.Failed) - { - _logger.LogError("Image upload failed for image with id {imageId}", image.Id); - return; - } - else if (@event.Status == RequestStatus.Success) - { - _logger.LogInformation("Image upload succeeded for image with id {imageId}", image.Id); - await _novelUpdateService.UpdateImage(image.Id, @event.FileAccessUrl); - } - } -} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Services/EventHandlers/NovelUpdateRequestedEventHandler.cs b/FictionArchive.Service.NovelService/Services/EventHandlers/NovelUpdateRequestedEventHandler.cs deleted file mode 100644 index 5fad1e0..0000000 --- a/FictionArchive.Service.NovelService/Services/EventHandlers/NovelUpdateRequestedEventHandler.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FictionArchive.Service.NovelService.Models.IntegrationEvents; -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.NovelService.Services.EventHandlers; - -public class NovelUpdateRequestedEventHandler : IIntegrationEventHandler -{ - private readonly ILogger _logger; - private readonly IEventBus _eventBus; - private readonly NovelUpdateService _novelUpdateService; - - public NovelUpdateRequestedEventHandler(ILogger logger, IEventBus eventBus, NovelUpdateService novelUpdateService) - { - _logger = logger; - _eventBus = eventBus; - _novelUpdateService = novelUpdateService; - } - - public async Task Handle(NovelUpdateRequestedEvent @event) - { - await _novelUpdateService.ImportNovel(@event.NovelUrl); - } -} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Services/EventHandlers/TranslationRequestCompletedEventHandler.cs b/FictionArchive.Service.NovelService/Services/EventHandlers/TranslationRequestCompletedEventHandler.cs deleted file mode 100644 index 632d26c..0000000 --- a/FictionArchive.Service.NovelService/Services/EventHandlers/TranslationRequestCompletedEventHandler.cs +++ /dev/null @@ -1,39 +0,0 @@ -using FictionArchive.Service.NovelService.Models.IntegrationEvents; -using FictionArchive.Service.NovelService.Models.Localization; -using FictionArchive.Service.Shared.Services.EventBus; -using Microsoft.EntityFrameworkCore; - -namespace FictionArchive.Service.NovelService.Services.EventHandlers; - -public class TranslationRequestCompletedEventHandler : IIntegrationEventHandler -{ - private readonly ILogger _logger; - private readonly NovelServiceDbContext _dbContext; - - public TranslationRequestCompletedEventHandler(ILogger logger, NovelServiceDbContext dbContext) - { - _logger = logger; - _dbContext = dbContext; - } - - public async Task Handle(TranslationRequestCompletedEvent @event) - { - var localizationRequest = await _dbContext.LocalizationRequests.Include(r => r.KeyRequestedForTranslation) - .ThenInclude(lk => lk.Texts) - .FirstOrDefaultAsync(lk => lk.Id == @event.TranslationRequestId); - if (localizationRequest == null) - { - // Not one of our requests, discard it - return; - } - - localizationRequest.KeyRequestedForTranslation.Texts.Add(new LocalizationText() - { - Language = localizationRequest.TranslateTo, - Text = @event.TranslatedText, - TranslationEngine = localizationRequest.Engine - }); - _dbContext.LocalizationRequests.Remove(localizationRequest); - await _dbContext.SaveChangesAsync(); - } -} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs index d95e865..4905a89 100644 --- a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs +++ b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs @@ -1,15 +1,15 @@ using FictionArchive.Common.Enums; -using FictionArchive.Service.FileService.IntegrationEvents; +using FictionArchive.Service.NovelService.Contracts; using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.Enums; using FictionArchive.Service.NovelService.Models.Images; -using FictionArchive.Service.NovelService.Models.IntegrationEvents; using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.NovelService.Models.Novels; using FictionArchive.Service.NovelService.Models.SourceAdapters; using FictionArchive.Service.NovelService.Services.SourceAdapters; -using FictionArchive.Service.Shared.Services.EventBus; +using FictionArchive.Service.Shared.Contracts.Events; using HtmlAgilityPack; +using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -20,15 +20,15 @@ public class NovelUpdateService private readonly NovelServiceDbContext _dbContext; private readonly ILogger _logger; private readonly IEnumerable _sourceAdapters; - private readonly IEventBus _eventBus; + private readonly IPublishEndpoint _publishEndpoint; private readonly NovelUpdateServiceConfiguration _novelUpdateServiceConfiguration; - public NovelUpdateService(NovelServiceDbContext dbContext, ILogger logger, IEnumerable sourceAdapters, IEventBus eventBus, IOptions novelUpdateServiceConfiguration) + public NovelUpdateService(NovelServiceDbContext dbContext, ILogger logger, IEnumerable sourceAdapters, IPublishEndpoint publishEndpoint, IOptions novelUpdateServiceConfiguration) { _dbContext = dbContext; _logger = logger; _sourceAdapters = sourceAdapters; - _eventBus = eventBus; + _publishEndpoint = publishEndpoint; _novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value; } @@ -335,7 +335,8 @@ public class NovelUpdateService .ThenInclude(volume => volume.Chapters) .ThenInclude(chapter => chapter.Body) .ThenInclude(localizationKey => localizationKey.Texts) - .Include(n => n.CoverImage) + .Include(n => n.CoverImage).Include(novel => novel.Volumes).ThenInclude(volume => volume.Chapters) + .ThenInclude(chapter => chapter.Name) .FirstOrDefaultAsync(n => n.ExternalId == metadata.ExternalId && n.Source.Key == metadata.SourceDescriptor.Key); @@ -393,14 +394,12 @@ public class NovelUpdateService // Publish novel created event for new novels if (existingNovel == null) { - await _eventBus.Publish(new NovelCreatedEvent - { - NovelId = novel.Id, - Title = novel.Name.Texts.First(t => t.Language == novel.RawLanguage).Text, - OriginalLanguage = novel.RawLanguage, - Source = novel.Source.Key, - AuthorName = novel.Author.Name.Texts.First(t => t.Language == novel.RawLanguage).Text - }); + await _publishEndpoint.Publish(new NovelCreated( + novel.Id, + novel.Name.Texts.First(t => t.Language == novel.RawLanguage).Text, + novel.RawLanguage, + novel.Source.Key, + novel.Author.Name.Texts.First(t => t.Language == novel.RawLanguage).Text)); } // Publish chapter created events for new chapters @@ -408,27 +407,23 @@ public class NovelUpdateService { foreach (var chapter in volume.Chapters.Where(c => !existingChapterIds.Contains(c.Id))) { - await _eventBus.Publish(new ChapterCreatedEvent - { - ChapterId = chapter.Id, - NovelId = novel.Id, - VolumeId = volume.Id, - VolumeOrder = volume.Order, - ChapterOrder = chapter.Order, - ChapterTitle = chapter.Name.Texts.First(t => t.Language == novel.RawLanguage).Text - }); + await _publishEndpoint.Publish(new ChapterCreated( + chapter.Id, + novel.Id, + volume.Id, + (uint)volume.Order, + chapter.Order, + chapter.Name.Texts.First(t => t.Language == novel.RawLanguage).Text)); } } // Publish cover image event if needed if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null) { - await _eventBus.Publish(new FileUploadRequestCreatedEvent - { - RequestId = novel.CoverImage.Id, - FileData = metadata.CoverImage.Data, - FilePath = $"Novels/{novel.Id}/Images/cover.jpg" - }); + await _publishEndpoint.Publish(new FileUploadRequestCreated( + novel.CoverImage.Id, + $"Novels/{novel.Id}/Images/cover.jpg", + metadata.CoverImage.Data)); } // Publish chapter pull events for chapters without body content @@ -440,12 +435,10 @@ public class NovelUpdateService foreach (var chapter in chaptersNeedingPull) { - await _eventBus.Publish(new ChapterPullRequestedEvent - { - NovelId = novel.Id, - VolumeId = volume.Id, - ChapterOrder = chapter.Order - }); + await _publishEndpoint.Publish(new ChapterPullRequested( + novel.Id, + volume.Id, + chapter.Order)); } } @@ -518,12 +511,10 @@ public class NovelUpdateService foreach (var image in chapter.Images) { var data = rawChapter.ImageData.FirstOrDefault(img => img.Url == image.OriginalPath); - await _eventBus.Publish(new FileUploadRequestCreatedEvent() - { - FileData = data.Data, - FilePath = $"{novel.Id}/Images/Chapter-{chapter.Id}/{imgCount++}.jpg", - RequestId = image.Id - }); + await _publishEndpoint.Publish(new FileUploadRequestCreated( + image.Id, + $"Novels/{novel.Id}/Images/Chapter-{chapter.Id}/{imgCount++}.jpg", + data.Data)); } return chapter; @@ -557,25 +548,17 @@ public class NovelUpdateService await _dbContext.SaveChangesAsync(); } - public async Task QueueNovelImport(string novelUrl) + public async Task QueueNovelImport(string novelUrl) { - var importNovelRequestEvent = new NovelUpdateRequestedEvent() - { - NovelUrl = novelUrl - }; - await _eventBus.Publish(importNovelRequestEvent); + var importNovelRequestEvent = new NovelUpdateRequested(novelUrl); + await _publishEndpoint.Publish(importNovelRequestEvent); return importNovelRequestEvent; } - public async Task QueueChapterPull(uint novelId, uint volumeId, uint chapterOrder) + public async Task QueueChapterPull(uint novelId, uint volumeId, uint chapterOrder) { - var chapterPullEvent = new ChapterPullRequestedEvent() - { - NovelId = novelId, - VolumeId = volumeId, - ChapterOrder = chapterOrder - }; - await _eventBus.Publish(chapterPullEvent); + var chapterPullEvent = new ChapterPullRequested(novelId, volumeId, chapterOrder); + await _publishEndpoint.Publish(chapterPullEvent); return chapterPullEvent; } diff --git a/FictionArchive.Service.NovelService/appsettings.json b/FictionArchive.Service.NovelService/appsettings.json index f287976..bdc841b 100644 --- a/FictionArchive.Service.NovelService/appsettings.json +++ b/FictionArchive.Service.NovelService/appsettings.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" } }, "Novelpia": { diff --git a/FictionArchive.Service.SchedulerService/Models/JobTemplates/EventJobTemplate.cs b/FictionArchive.Service.SchedulerService/Models/JobTemplates/EventJobTemplate.cs index fa4e8fd..f3d0db4 100644 --- a/FictionArchive.Service.SchedulerService/Models/JobTemplates/EventJobTemplate.cs +++ b/FictionArchive.Service.SchedulerService/Models/JobTemplates/EventJobTemplate.cs @@ -1,4 +1,4 @@ -using FictionArchive.Service.Shared.Services.EventBus; +using MassTransit; using Newtonsoft.Json; using Quartz; @@ -6,18 +6,18 @@ namespace FictionArchive.Service.SchedulerService.Models.JobTemplates; public class EventJobTemplate : IJob { - private readonly IEventBus _eventBus; + private readonly IBus _bus; private readonly ILogger _logger; public const string EventTypeParameter = "RoutingKey"; public const string EventDataParameter = "MessageData"; - - public EventJobTemplate(IEventBus eventBus, ILogger logger) + + public EventJobTemplate(IBus bus, ILogger logger) { - _eventBus = eventBus; + _bus = bus; _logger = logger; } - + public async Task Execute(IJobExecutionContext context) { try @@ -25,7 +25,7 @@ public class EventJobTemplate : IJob var eventData = context.MergedJobDataMap.GetString(EventDataParameter); var eventType = context.MergedJobDataMap.GetString(EventTypeParameter); var eventObject = JsonConvert.DeserializeObject(eventData); - await _eventBus.Publish(eventObject, eventType); + await _bus.Publish(eventObject); } catch (Exception ex) { diff --git a/FictionArchive.Service.SchedulerService/Program.cs b/FictionArchive.Service.SchedulerService/Program.cs index b1a9c92..e20ed4d 100644 --- a/FictionArchive.Service.SchedulerService/Program.cs +++ b/FictionArchive.Service.SchedulerService/Program.cs @@ -2,7 +2,6 @@ using FictionArchive.Service.SchedulerService.GraphQL; using FictionArchive.Service.SchedulerService.Services; using FictionArchive.Service.Shared; using FictionArchive.Service.Shared.Extensions; -using FictionArchive.Service.Shared.Services.EventBus.Implementations; using Quartz; using Quartz.Impl.AdoJobStore; @@ -38,10 +37,7 @@ public class Program if (!isSchemaExport) { - builder.Services.AddRabbitMQ(opt => - { - builder.Configuration.GetSection("RabbitMQ").Bind(opt); - }); + builder.Services.AddFictionArchiveMassTransit(builder.Configuration); } #endregion diff --git a/FictionArchive.Service.SchedulerService/Services/JobManagerService.cs b/FictionArchive.Service.SchedulerService/Services/JobManagerService.cs index 1e37f75..fa9404b 100644 --- a/FictionArchive.Service.SchedulerService/Services/JobManagerService.cs +++ b/FictionArchive.Service.SchedulerService/Services/JobManagerService.cs @@ -1,7 +1,6 @@ using System.Data; using FictionArchive.Service.SchedulerService.Models; using FictionArchive.Service.SchedulerService.Models.JobTemplates; -using FictionArchive.Service.Shared.Services.EventBus; using Quartz; using Quartz.Impl.Matchers; diff --git a/FictionArchive.Service.SchedulerService/appsettings.json b/FictionArchive.Service.SchedulerService/appsettings.json index 4b087a3..7a3f4bf 100644 --- a/FictionArchive.Service.SchedulerService/appsettings.json +++ b/FictionArchive.Service.SchedulerService/appsettings.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" } }, "RabbitMQ": { diff --git a/FictionArchive.Service.Shared/Contracts/Events/IChapterCreated.cs b/FictionArchive.Service.Shared/Contracts/Events/IChapterCreated.cs new file mode 100644 index 0000000..9edc77b --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/IChapterCreated.cs @@ -0,0 +1,11 @@ +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface IChapterCreated +{ + uint ChapterId { get; } + uint NovelId { get; } + uint VolumeId { get; } + uint VolumeOrder { get; } + uint ChapterOrder { get; } + string ChapterTitle { get; } +} diff --git a/FictionArchive.Service.Shared/Contracts/Events/IChapterPullRequested.cs b/FictionArchive.Service.Shared/Contracts/Events/IChapterPullRequested.cs new file mode 100644 index 0000000..a0f3377 --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/IChapterPullRequested.cs @@ -0,0 +1,8 @@ +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface IChapterPullRequested +{ + uint NovelId { get; } + uint VolumeId { get; } + uint ChapterOrder { get; } +} diff --git a/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestCreated.cs b/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestCreated.cs new file mode 100644 index 0000000..8624ce5 --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestCreated.cs @@ -0,0 +1,8 @@ +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface IFileUploadRequestCreated +{ + Guid RequestId { get; } + string FilePath { get; } + byte[] FileData { get; } +} diff --git a/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestStatusUpdate.cs b/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestStatusUpdate.cs new file mode 100644 index 0000000..bd7380c --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestStatusUpdate.cs @@ -0,0 +1,11 @@ +using FictionArchive.Common.Enums; + +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface IFileUploadRequestStatusUpdate +{ + Guid RequestId { get; } + RequestStatus Status { get; } + string? FileAccessUrl { get; } + string? ErrorMessage { get; } +} diff --git a/FictionArchive.Service.Shared/Contracts/Events/INovelCreated.cs b/FictionArchive.Service.Shared/Contracts/Events/INovelCreated.cs new file mode 100644 index 0000000..071048b --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/INovelCreated.cs @@ -0,0 +1,12 @@ +using FictionArchive.Common.Enums; + +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface INovelCreated +{ + uint NovelId { get; } + string Title { get; } + Language OriginalLanguage { get; } + string Source { get; } + string AuthorName { get; } +} diff --git a/FictionArchive.Service.Shared/Contracts/Events/INovelUpdateRequested.cs b/FictionArchive.Service.Shared/Contracts/Events/INovelUpdateRequested.cs new file mode 100644 index 0000000..d64ac3f --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/INovelUpdateRequested.cs @@ -0,0 +1,6 @@ +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface INovelUpdateRequested +{ + string NovelUrl { get; } +} diff --git a/FictionArchive.Service.Shared/Contracts/Events/ITranslationRequestCompleted.cs b/FictionArchive.Service.Shared/Contracts/Events/ITranslationRequestCompleted.cs new file mode 100644 index 0000000..f16eef3 --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/ITranslationRequestCompleted.cs @@ -0,0 +1,7 @@ +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface ITranslationRequestCompleted +{ + Guid? TranslationRequestId { get; } + string? TranslatedText { get; } +} diff --git a/FictionArchive.Service.Shared/Contracts/Events/ITranslationRequestCreated.cs b/FictionArchive.Service.Shared/Contracts/Events/ITranslationRequestCreated.cs new file mode 100644 index 0000000..1fbe7a0 --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/ITranslationRequestCreated.cs @@ -0,0 +1,12 @@ +using FictionArchive.Common.Enums; + +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface ITranslationRequestCreated +{ + Guid TranslationRequestId { get; } + Language From { get; } + Language To { get; } + string Body { get; } + string TranslationEngineKey { get; } +} diff --git a/FictionArchive.Service.Shared/Contracts/Events/IUserInvited.cs b/FictionArchive.Service.Shared/Contracts/Events/IUserInvited.cs new file mode 100644 index 0000000..44ecd64 --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/IUserInvited.cs @@ -0,0 +1,12 @@ +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface IUserInvited +{ + string InvitedUserId { get; } + string InvitedUsername { get; } + string InvitedEmail { get; } + string InvitedOAuthProviderId { get; } + string InviterId { get; } + string InviterUsername { get; } + string InviterOAuthProviderId { get; } +} diff --git a/FictionArchive.Service.Shared/Extensions/MassTransitExtensions.cs b/FictionArchive.Service.Shared/Extensions/MassTransitExtensions.cs new file mode 100644 index 0000000..0879e72 --- /dev/null +++ b/FictionArchive.Service.Shared/Extensions/MassTransitExtensions.cs @@ -0,0 +1,118 @@ +using System.Text.RegularExpressions; +using FictionArchive.Service.Shared.Services.Filters; +using MassTransit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace FictionArchive.Service.Shared.Extensions; + +public static class MassTransitExtensions +{ + public static IServiceCollection AddFictionArchiveMassTransit( + this IServiceCollection services, + IConfiguration configuration, + Action? configureConsumers = null) + { + services.AddMassTransit(x => + { + configureConsumers?.Invoke(x); + + x.UsingRabbitMq((context, cfg) => + { + var (host, username, password) = ParseRabbitMqConfiguration(configuration); + + cfg.Host(host, h => + { + h.Username(username); + h.Password(password); + }); + + cfg.UseMessageRetry(r => r.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromSeconds(1), + maxInterval: TimeSpan.FromMinutes(1), + intervalDelta: TimeSpan.FromSeconds(2))); + + cfg.UseConsumeFilter(typeof(LoggingConsumeFilter<>), context); + + // Process one message at a time per consumer (matches old EventBus behavior) + cfg.PrefetchCount = 1; + + cfg.ConfigureEndpoints(context); + }); + }); + + return services; + } + + /// + /// Parses RabbitMQ configuration from either ConnectionString format or separate Host/Username/Password keys. + /// ConnectionString format: amqp://[username:password@]host[:port] + /// + private static (string Host, string Username, string Password) ParseRabbitMqConfiguration(IConfiguration configuration) + { + var connectionString = configuration["RabbitMQ:ConnectionString"]; + + if (!string.IsNullOrEmpty(connectionString)) + { + return ParseConnectionString(connectionString); + } + + // Fallback to separate configuration keys + var host = configuration["RabbitMQ:Host"] ?? "localhost"; + var username = configuration["RabbitMQ:Username"] ?? "guest"; + var password = configuration["RabbitMQ:Password"] ?? "guest"; + + return (host, username, password); + } + + /// + /// Parses an AMQP connection string into host, username, and password components. + /// Supports formats: + /// - amqp://host + /// - amqp://host:port + /// - amqp://username:password@host + /// - amqp://username:password@host:port + /// + private static (string Host, string Username, string Password) ParseConnectionString(string connectionString) + { + var username = "guest"; + var password = "guest"; + var host = "localhost"; + + // Try to parse as URI first + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + host = uri.Host; + + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + var userInfoParts = uri.UserInfo.Split(':', 2); + username = Uri.UnescapeDataString(userInfoParts[0]); + if (userInfoParts.Length > 1) + { + password = Uri.UnescapeDataString(userInfoParts[1]); + } + } + } + else + { + // Fallback regex parsing for edge cases + var match = Regex.Match(connectionString, @"amqp://(?:([^:]+):([^@]+)@)?([^:/]+)"); + if (match.Success) + { + if (match.Groups[1].Success && match.Groups[2].Success) + { + username = match.Groups[1].Value; + password = match.Groups[2].Value; + } + if (match.Groups[3].Success) + { + host = match.Groups[3].Value; + } + } + } + + return (host, username, password); + } +} diff --git a/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj b/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj index 323b166..9cbe796 100644 --- a/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj +++ b/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj @@ -31,7 +31,7 @@ - + diff --git a/FictionArchive.Service.Shared/Services/EventBus/EventBusBuilder.cs b/FictionArchive.Service.Shared/Services/EventBus/EventBusBuilder.cs deleted file mode 100644 index 1331fba..0000000 --- a/FictionArchive.Service.Shared/Services/EventBus/EventBusBuilder.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace FictionArchive.Service.Shared.Services.EventBus; - -public class EventBusBuilder where TEventBus : class, IEventBus -{ - private readonly IServiceCollection _services; - private readonly SubscriptionManager _subscriptionManager; - - public EventBusBuilder(IServiceCollection services) - { - _services = services; - _services.AddSingleton(); - - _subscriptionManager = new SubscriptionManager(); - _services.AddSingleton(_subscriptionManager); - } - - public EventBusBuilder Subscribe() where TEvent : IIntegrationEvent where TEventHandler : class, IIntegrationEventHandler - { - _services.AddKeyedTransient(typeof(TEvent).Name); - _subscriptionManager.RegisterSubscription(); - return this; - } -} \ No newline at end of file diff --git a/FictionArchive.Service.Shared/Services/EventBus/EventBusExtensions.cs b/FictionArchive.Service.Shared/Services/EventBus/EventBusExtensions.cs deleted file mode 100644 index 417bfa8..0000000 --- a/FictionArchive.Service.Shared/Services/EventBus/EventBusExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace FictionArchive.Service.Shared.Services.EventBus; - -public static class EventBusExtensions -{ - public static EventBusBuilder AddEventBus(this IServiceCollection services) - where TEventBus : class, IEventBus - { - return new EventBusBuilder(services); - } -} \ No newline at end of file diff --git a/FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs b/FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs deleted file mode 100644 index 46b28eb..0000000 --- a/FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FictionArchive.Service.Shared.Services.EventBus; - -public interface IEventBus -{ - Task Publish(TEvent integrationEvent) where TEvent : IIntegrationEvent; - Task Publish(object integrationEvent, string eventType); -} \ No newline at end of file diff --git a/FictionArchive.Service.Shared/Services/EventBus/IIntegrationEvent.cs b/FictionArchive.Service.Shared/Services/EventBus/IIntegrationEvent.cs deleted file mode 100644 index 499f92c..0000000 --- a/FictionArchive.Service.Shared/Services/EventBus/IIntegrationEvent.cs +++ /dev/null @@ -1,7 +0,0 @@ -using NodaTime; - -namespace FictionArchive.Service.Shared.Services.EventBus; - -public interface IIntegrationEvent -{ -} \ No newline at end of file diff --git a/FictionArchive.Service.Shared/Services/EventBus/IIntegrationEventHandler.cs b/FictionArchive.Service.Shared/Services/EventBus/IIntegrationEventHandler.cs deleted file mode 100644 index 0541637..0000000 --- a/FictionArchive.Service.Shared/Services/EventBus/IIntegrationEventHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace FictionArchive.Service.Shared.Services.EventBus; - -public interface IIntegrationEventHandler : IIntegrationEventHandler where TEvent : IIntegrationEvent -{ - Task Handle(TEvent @event); - Task IIntegrationEventHandler.Handle(IIntegrationEvent @event) => Handle((TEvent)@event); -} - -public interface IIntegrationEventHandler -{ - Task Handle(IIntegrationEvent @event); -} \ No newline at end of file diff --git a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQConnectionProvider.cs b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQConnectionProvider.cs deleted file mode 100644 index ad93539..0000000 --- a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQConnectionProvider.cs +++ /dev/null @@ -1,35 +0,0 @@ -using RabbitMQ.Client; - -namespace FictionArchive.Service.Shared.Services.EventBus.Implementations; - -public class RabbitMQConnectionProvider -{ - private readonly IConnectionFactory _connectionFactory; - - private IConnection Connection { get; set; } - private IChannel DefaultChannel { get; set; } - - public RabbitMQConnectionProvider(IConnectionFactory connectionFactory) - { - _connectionFactory = connectionFactory; - } - - public async Task GetConnectionAsync() - { - if (Connection == null) - { - Connection = await _connectionFactory.CreateConnectionAsync(); - } - - return Connection; - } - - public async Task GetDefaultChannelAsync() - { - if (DefaultChannel == null) - { - DefaultChannel = await (await GetConnectionAsync()).CreateChannelAsync(); - } - return DefaultChannel; - } -} \ No newline at end of file diff --git a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs deleted file mode 100644 index 758f2d8..0000000 --- a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Text; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Newtonsoft.Json; -using NodaTime; -using NodaTime.Serialization.JsonNet; -using RabbitMQ.Client; -using RabbitMQ.Client.Events; - -namespace FictionArchive.Service.Shared.Services.EventBus.Implementations; - -public class RabbitMQEventBus : IEventBus, IHostedService -{ - private readonly IServiceScopeFactory _serviceScopeFactory; - private readonly RabbitMQConnectionProvider _connectionProvider; - private readonly RabbitMQOptions _options; - private readonly SubscriptionManager _subscriptionManager; - private readonly ILogger _logger; - - private readonly JsonSerializerSettings _jsonSerializerSettings; - - private const string ExchangeName = "fiction-archive-event-bus"; - private const string CreatedAtHeader = "X-Created-At"; - private const string EventIdHeader = "X-Event-Id"; - - public RabbitMQEventBus(IServiceScopeFactory serviceScopeFactory, RabbitMQConnectionProvider connectionProvider, IOptions options, SubscriptionManager subscriptionManager, ILogger logger) - { - _serviceScopeFactory = serviceScopeFactory; - _connectionProvider = connectionProvider; - _subscriptionManager = subscriptionManager; - _logger = logger; - _options = options.Value; - _jsonSerializerSettings = new JsonSerializerSettings().ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); - } - - public async Task Publish(TEvent integrationEvent) where TEvent : IIntegrationEvent - { - var routingKey = typeof(TEvent).Name; - - await Publish(integrationEvent, routingKey); - } - - public async Task Publish(object integrationEvent, string eventType) - { - var channel = await _connectionProvider.GetDefaultChannelAsync(); - var body = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(integrationEvent)); - - // headers - var props = new BasicProperties(); - props.Headers = new Dictionary() - { - { CreatedAtHeader, Instant.FromDateTimeUtc(DateTime.UtcNow).ToString() }, - { EventIdHeader, Guid.NewGuid().ToString() } - }; - - await channel.BasicPublishAsync(ExchangeName, eventType, true, props, body); - _logger.LogInformation("Published event {EventName}", eventType); - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - _ = Task.Factory.StartNew(async () => - { - try - { - var channel = await _connectionProvider.GetDefaultChannelAsync(); - await channel.ExchangeDeclareAsync(ExchangeName, ExchangeType.Direct, - cancellationToken: cancellationToken); - - await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, cancellationToken: cancellationToken); - await channel.QueueDeclareAsync(_options.ClientIdentifier, true, false, false, - cancellationToken: cancellationToken); - var consumer = new AsyncEventingBasicConsumer(channel); - consumer.ReceivedAsync += (sender, @event) => - { - return OnReceivedEvent(sender, @event, channel); - }; - - await channel.BasicConsumeAsync(_options.ClientIdentifier, false, consumer, cancellationToken: cancellationToken); - - foreach (var subscription in _subscriptionManager.Subscriptions) - { - await channel.QueueBindAsync(_options.ClientIdentifier, ExchangeName, subscription.Key, - cancellationToken: cancellationToken); - _logger.LogInformation("Subscribed to {SubscriptionKey}", subscription.Key); - } - - _logger.LogInformation("RabbitMQ EventBus started."); - } - catch (Exception e) - { - _logger.LogError(e, "An error occurred while starting the RabbitMQ EventBus"); - } - }, cancellationToken); - } - - public Task StopAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - private async Task OnReceivedEvent(object sender, BasicDeliverEventArgs @event, IChannel channel) - { - var eventName = @event.RoutingKey; - _logger.LogInformation("Received event {EventName}", eventName); - try - { - if (!_subscriptionManager.Subscriptions.ContainsKey(eventName)) - { - _logger.LogWarning("Received event without subscription entry."); - return; - } - - var eventBody = Encoding.UTF8.GetString(@event.Body.Span); - var eventObject = JsonConvert.DeserializeObject(eventBody, _subscriptionManager.Subscriptions[eventName], _jsonSerializerSettings) as IIntegrationEvent; - - using var scope = _serviceScopeFactory.CreateScope(); - - foreach (var service in scope.ServiceProvider.GetKeyedServices(eventName)) - { - await service.Handle(eventObject); - } - _logger.LogInformation("Finished handling event with name {EventName}", eventName); - } - catch (Exception e) - { - _logger.LogError(e, "An error occurred while handling an event."); - } - finally - { - await channel.BasicAckAsync(@event.DeliveryTag, false); - } - - } -} \ No newline at end of file diff --git a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQExtensions.cs b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQExtensions.cs deleted file mode 100644 index 14546d4..0000000 --- a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using RabbitMQ.Client; - -namespace FictionArchive.Service.Shared.Services.EventBus.Implementations; - -public static class RabbitMQExtensions -{ - public static EventBusBuilder AddRabbitMQ(this IServiceCollection services, Action configure) - { - services.Configure(configure); - services.AddSingleton(provider => - { - var options = provider.GetService>(); - ConnectionFactory factory = new ConnectionFactory(); - factory.Uri = new Uri(options.Value.ConnectionString); - return factory; - }); - services.AddSingleton(); - services.AddHostedService(); - return services.AddEventBus(); - } -} \ No newline at end of file diff --git a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQOptions.cs b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQOptions.cs deleted file mode 100644 index f385a35..0000000 --- a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQOptions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FictionArchive.Service.Shared.Services.EventBus.Implementations; - -public class RabbitMQOptions -{ - public string ConnectionString { get; set; } - public string ClientIdentifier { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.Shared/Services/EventBus/SubscriptionManager.cs b/FictionArchive.Service.Shared/Services/EventBus/SubscriptionManager.cs deleted file mode 100644 index 73b8c37..0000000 --- a/FictionArchive.Service.Shared/Services/EventBus/SubscriptionManager.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace FictionArchive.Service.Shared.Services.EventBus; - -public class SubscriptionManager -{ - public Dictionary Subscriptions { get; } = new Dictionary(); - - public void RegisterSubscription() - { - Subscriptions.Add(typeof(TEvent).Name, typeof(TEvent)); - } -} \ No newline at end of file diff --git a/FictionArchive.Service.Shared/Services/Filters/LoggingConsumeFilter.cs b/FictionArchive.Service.Shared/Services/Filters/LoggingConsumeFilter.cs new file mode 100644 index 0000000..2b3a539 --- /dev/null +++ b/FictionArchive.Service.Shared/Services/Filters/LoggingConsumeFilter.cs @@ -0,0 +1,33 @@ +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace FictionArchive.Service.Shared.Services.Filters; + +public class LoggingConsumeFilter : IFilter> where T : class +{ + private readonly ILogger> _logger; + + public LoggingConsumeFilter(ILogger> logger) + { + _logger = logger; + } + + public async Task Send(ConsumeContext context, IPipe> next) + { + try + { + await next.Send(context); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Message {MessageType} failed after all retries. MessageId: {MessageId}, ConversationId: {ConversationId}", + typeof(T).Name, + context.MessageId, + context.ConversationId); + throw; + } + } + + public void Probe(ProbeContext context) => context.CreateFilterScope("logging"); +} diff --git a/FictionArchive.Service.TranslationService/Consumers/TranslationRequestCreatedConsumer.cs b/FictionArchive.Service.TranslationService/Consumers/TranslationRequestCreatedConsumer.cs new file mode 100644 index 0000000..e911c47 --- /dev/null +++ b/FictionArchive.Service.TranslationService/Consumers/TranslationRequestCreatedConsumer.cs @@ -0,0 +1,53 @@ +using FictionArchive.Service.Shared.Contracts.Events; +using FictionArchive.Service.TranslationService.Contracts; +using FictionArchive.Service.TranslationService.Models.Enums; +using FictionArchive.Service.TranslationService.Services; +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace FictionArchive.Service.TranslationService.Consumers; + +public class TranslationRequestCreatedConsumer : IConsumer +{ + private readonly ILogger _logger; + private readonly TranslationEngineService _translationEngineService; + private readonly IPublishEndpoint _publishEndpoint; + + public TranslationRequestCreatedConsumer( + ILogger logger, + TranslationEngineService translationEngineService, + IPublishEndpoint publishEndpoint) + { + _logger = logger; + _translationEngineService = translationEngineService; + _publishEndpoint = publishEndpoint; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + _logger.LogInformation("Processing translation request {TranslationRequestId}", message.TranslationRequestId); + + var result = await _translationEngineService.Translate( + message.From, + message.To, + message.Body, + message.TranslationEngineKey); + + if (result.Status == TranslationRequestStatus.Success) + { + await _publishEndpoint.Publish( + new TranslationRequestCompleted( + TranslationRequestId: message.TranslationRequestId, + TranslatedText: result.TranslatedText)); + + _logger.LogInformation("Translation completed for request {TranslationRequestId}", message.TranslationRequestId); + } + else + { + _logger.LogError("Translation failed for request {TranslationRequestId}", message.TranslationRequestId); + throw new InvalidOperationException($"Translation failed for request {message.TranslationRequestId}"); + } + } +} diff --git a/FictionArchive.Service.TranslationService/Contracts/TranslationRequestCompleted.cs b/FictionArchive.Service.TranslationService/Contracts/TranslationRequestCompleted.cs new file mode 100644 index 0000000..4281c30 --- /dev/null +++ b/FictionArchive.Service.TranslationService/Contracts/TranslationRequestCompleted.cs @@ -0,0 +1,7 @@ +using FictionArchive.Service.Shared.Contracts.Events; + +namespace FictionArchive.Service.TranslationService.Contracts; + +public record TranslationRequestCompleted( + Guid? TranslationRequestId, + string? TranslatedText) : ITranslationRequestCompleted; diff --git a/FictionArchive.Service.TranslationService/Models/IntegrationEvents/TranslationRequestCompletedEvent.cs b/FictionArchive.Service.TranslationService/Models/IntegrationEvents/TranslationRequestCompletedEvent.cs deleted file mode 100644 index e2ead5f..0000000 --- a/FictionArchive.Service.TranslationService/Models/IntegrationEvents/TranslationRequestCompletedEvent.cs +++ /dev/null @@ -1,18 +0,0 @@ -using FictionArchive.Common.Enums; -using FictionArchive.Service.Shared.Services.EventBus; -using FictionArchive.Service.TranslationService.Models.Enums; - -namespace FictionArchive.Service.TranslationService.Models.IntegrationEvents; - -public class TranslationRequestCompletedEvent : IIntegrationEvent -{ - /// - /// Maps this event back to a triggering request. - /// - public Guid? TranslationRequestId { get; set; } - - /// - /// The resulting text. - /// - public string? TranslatedText { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.TranslationService/Models/IntegrationEvents/TranslationRequestCreatedEvent.cs b/FictionArchive.Service.TranslationService/Models/IntegrationEvents/TranslationRequestCreatedEvent.cs deleted file mode 100644 index 4d627cb..0000000 --- a/FictionArchive.Service.TranslationService/Models/IntegrationEvents/TranslationRequestCreatedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FictionArchive.Common.Enums; -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.TranslationService.Models.IntegrationEvents; - -public class TranslationRequestCreatedEvent : IIntegrationEvent -{ - public Guid TranslationRequestId { get; set; } - public Language From { get; set; } - public Language To { get; set; } - public string Body { get; set; } - public string TranslationEngineKey { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.TranslationService/Program.cs b/FictionArchive.Service.TranslationService/Program.cs index 9f4972c..bcd9fd1 100644 --- a/FictionArchive.Service.TranslationService/Program.cs +++ b/FictionArchive.Service.TranslationService/Program.cs @@ -2,16 +2,13 @@ using DeepL; using FictionArchive.Common.Extensions; using FictionArchive.Service.Shared; using FictionArchive.Service.Shared.Extensions; -using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.Shared.Services.GraphQL; +using FictionArchive.Service.TranslationService.Consumers; using FictionArchive.Service.TranslationService.GraphQL; -using FictionArchive.Service.TranslationService.Models.IntegrationEvents; using FictionArchive.Service.TranslationService.Services; using FictionArchive.Service.TranslationService.Services.Database; -using FictionArchive.Service.TranslationService.Services.EventHandlers; using FictionArchive.Service.TranslationService.Services.TranslationEngines; using FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate; -using RabbitMQ.Client; namespace FictionArchive.Service.TranslationService; @@ -26,15 +23,16 @@ public class Program builder.Services.AddHealthChecks(); - #region Event Bus + #region MassTransit if (!isSchemaExport) { - builder.Services.AddRabbitMQ(opt => - { - builder.Configuration.GetSection("RabbitMQ").Bind(opt); - }) - .Subscribe(); + builder.Services.AddFictionArchiveMassTransit( + builder.Configuration, + x => + { + x.AddConsumer(); + }); } #endregion diff --git a/FictionArchive.Service.TranslationService/Services/EventHandlers/TranslationRequestCreatedEventHandler.cs b/FictionArchive.Service.TranslationService/Services/EventHandlers/TranslationRequestCreatedEventHandler.cs deleted file mode 100644 index a824d34..0000000 --- a/FictionArchive.Service.TranslationService/Services/EventHandlers/TranslationRequestCreatedEventHandler.cs +++ /dev/null @@ -1,31 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; -using FictionArchive.Service.TranslationService.Models.Enums; -using FictionArchive.Service.TranslationService.Models.IntegrationEvents; - -namespace FictionArchive.Service.TranslationService.Services.EventHandlers; - -public class TranslationRequestCreatedEventHandler : IIntegrationEventHandler -{ - private readonly ILogger _logger; - private readonly TranslationEngineService _translationEngineService; - private readonly IEventBus _eventBus; - - public TranslationRequestCreatedEventHandler(ILogger logger, TranslationEngineService translationEngineService) - { - _logger = logger; - _translationEngineService = translationEngineService; - } - - public async Task Handle(TranslationRequestCreatedEvent @event) - { - var result = await _translationEngineService.Translate(@event.From, @event.To, @event.Body, @event.TranslationEngineKey); - if (result.Status == TranslationRequestStatus.Success) - { - await _eventBus.Publish(new TranslationRequestCompletedEvent() - { - TranslatedText = result.TranslatedText, - TranslationRequestId = @event.TranslationRequestId, - }); - } - } -} \ No newline at end of file diff --git a/FictionArchive.Service.TranslationService/Services/TranslationEngineService.cs b/FictionArchive.Service.TranslationService/Services/TranslationEngineService.cs index 3768704..7db19a3 100644 --- a/FictionArchive.Service.TranslationService/Services/TranslationEngineService.cs +++ b/FictionArchive.Service.TranslationService/Services/TranslationEngineService.cs @@ -1,28 +1,21 @@ -using System.Text; using FictionArchive.Common.Enums; -using FictionArchive.Service.Shared.Services.EventBus; -using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.TranslationService.Models; using FictionArchive.Service.TranslationService.Models.Database; using FictionArchive.Service.TranslationService.Models.Enums; -using FictionArchive.Service.TranslationService.Models.IntegrationEvents; using FictionArchive.Service.TranslationService.Services.Database; using FictionArchive.Service.TranslationService.Services.TranslationEngines; -using RabbitMQ.Client; namespace FictionArchive.Service.TranslationService.Services; public class TranslationEngineService { private readonly IEnumerable _translationEngines; - private readonly IEventBus _eventBus; private readonly TranslationServiceDbContext _dbContext; - public TranslationEngineService(IEnumerable translationEngines, TranslationServiceDbContext dbContext, IEventBus eventBus) + public TranslationEngineService(IEnumerable translationEngines, TranslationServiceDbContext dbContext) { _translationEngines = translationEngines; _dbContext = dbContext; - _eventBus = eventBus; } public async Task Translate(Language from, Language to, string text, string translationEngineKey) diff --git a/FictionArchive.Service.TranslationService/appsettings.json b/FictionArchive.Service.TranslationService/appsettings.json index 7f2534b..9d1b67d 100644 --- a/FictionArchive.Service.TranslationService/appsettings.json +++ b/FictionArchive.Service.TranslationService/appsettings.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" } }, "DeepL": { diff --git a/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/ChapterCreatedEventHandler.cs b/FictionArchive.Service.UserNovelDataService/Consumers/ChapterCreatedConsumer.cs similarity index 52% rename from FictionArchive.Service.UserNovelDataService/Services/EventHandlers/ChapterCreatedEventHandler.cs rename to FictionArchive.Service.UserNovelDataService/Consumers/ChapterCreatedConsumer.cs index 74f46f7..293bb4a 100644 --- a/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/ChapterCreatedEventHandler.cs +++ b/FictionArchive.Service.UserNovelDataService/Consumers/ChapterCreatedConsumer.cs @@ -1,53 +1,56 @@ -using FictionArchive.Service.Shared.Services.EventBus; +using FictionArchive.Service.Shared.Contracts.Events; using FictionArchive.Service.UserNovelDataService.Models.Database; -using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; +using FictionArchive.Service.UserNovelDataService.Services; +using MassTransit; using Microsoft.EntityFrameworkCore; -namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers; +namespace FictionArchive.Service.UserNovelDataService.Consumers; -public class ChapterCreatedEventHandler : IIntegrationEventHandler +public class ChapterCreatedConsumer : IConsumer { + private readonly ILogger _logger; private readonly UserNovelDataServiceDbContext _dbContext; - private readonly ILogger _logger; - public ChapterCreatedEventHandler( - UserNovelDataServiceDbContext dbContext, - ILogger logger) + public ChapterCreatedConsumer( + ILogger logger, + UserNovelDataServiceDbContext dbContext) { - _dbContext = dbContext; _logger = logger; + _dbContext = dbContext; } - public async Task Handle(ChapterCreatedEvent @event) + public async Task Consume(ConsumeContext context) { + var message = context.Message; + // Ensure novel exists - var novelExists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId); + var novelExists = await _dbContext.Novels.AnyAsync(n => n.Id == message.NovelId); if (!novelExists) { - var novel = new Novel { Id = @event.NovelId }; + var novel = new Novel { Id = message.NovelId }; _dbContext.Novels.Add(novel); } // Ensure volume exists - var volumeExists = await _dbContext.Volumes.AnyAsync(v => v.Id == @event.VolumeId); + var volumeExists = await _dbContext.Volumes.AnyAsync(v => v.Id == message.VolumeId); if (!volumeExists) { - var volume = new Volume { Id = @event.VolumeId }; + var volume = new Volume { Id = message.VolumeId }; _dbContext.Volumes.Add(volume); } // Create chapter if not exists - var chapterExists = await _dbContext.Chapters.AnyAsync(c => c.Id == @event.ChapterId); + var chapterExists = await _dbContext.Chapters.AnyAsync(c => c.Id == message.ChapterId); if (chapterExists) { - _logger.LogDebug("Chapter {ChapterId} already exists, skipping", @event.ChapterId); + _logger.LogDebug("Chapter {ChapterId} already exists, skipping", message.ChapterId); return; } - var chapter = new Chapter { Id = @event.ChapterId }; + var chapter = new Chapter { Id = message.ChapterId }; _dbContext.Chapters.Add(chapter); await _dbContext.SaveChangesAsync(); - _logger.LogInformation("Created chapter stub for {ChapterId} in novel {NovelId}", @event.ChapterId, @event.NovelId); + _logger.LogInformation("Created chapter stub for {ChapterId} in novel {NovelId}", message.ChapterId, message.NovelId); } } diff --git a/FictionArchive.Service.UserNovelDataService/Consumers/NovelCreatedConsumer.cs b/FictionArchive.Service.UserNovelDataService/Consumers/NovelCreatedConsumer.cs new file mode 100644 index 0000000..97d3603 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Consumers/NovelCreatedConsumer.cs @@ -0,0 +1,39 @@ +using FictionArchive.Service.Shared.Contracts.Events; +using FictionArchive.Service.UserNovelDataService.Models.Database; +using FictionArchive.Service.UserNovelDataService.Services; +using MassTransit; +using Microsoft.EntityFrameworkCore; + +namespace FictionArchive.Service.UserNovelDataService.Consumers; + +public class NovelCreatedConsumer : IConsumer +{ + private readonly ILogger _logger; + private readonly UserNovelDataServiceDbContext _dbContext; + + public NovelCreatedConsumer( + ILogger logger, + UserNovelDataServiceDbContext dbContext) + { + _logger = logger; + _dbContext = dbContext; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + var exists = await _dbContext.Novels.AnyAsync(n => n.Id == message.NovelId); + if (exists) + { + _logger.LogDebug("Novel {NovelId} already exists, skipping", message.NovelId); + return; + } + + var novel = new Novel { Id = message.NovelId }; + _dbContext.Novels.Add(novel); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Created novel stub for {NovelId}", message.NovelId); + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Consumers/UserInvitedConsumer.cs b/FictionArchive.Service.UserNovelDataService/Consumers/UserInvitedConsumer.cs new file mode 100644 index 0000000..5c7adf0 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Consumers/UserInvitedConsumer.cs @@ -0,0 +1,44 @@ +using FictionArchive.Service.Shared.Contracts.Events; +using FictionArchive.Service.UserNovelDataService.Models.Database; +using FictionArchive.Service.UserNovelDataService.Services; +using MassTransit; +using Microsoft.EntityFrameworkCore; + +namespace FictionArchive.Service.UserNovelDataService.Consumers; + +public class UserInvitedConsumer : IConsumer +{ + private readonly ILogger _logger; + private readonly UserNovelDataServiceDbContext _dbContext; + + public UserInvitedConsumer( + ILogger logger, + UserNovelDataServiceDbContext dbContext) + { + _logger = logger; + _dbContext = dbContext; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + var userId = Guid.Parse(message.InvitedUserId); + var exists = await _dbContext.Users.AnyAsync(u => u.Id == userId); + if (exists) + { + _logger.LogDebug("User {UserId} already exists, skipping", message.InvitedUserId); + return; + } + + var user = new User + { + Id = userId, + OAuthProviderId = message.InvitedOAuthProviderId + }; + _dbContext.Users.Add(user); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Created user stub for {UserId}", message.InvitedUserId); + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/ChapterCreatedEvent.cs b/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/ChapterCreatedEvent.cs deleted file mode 100644 index 2591f68..0000000 --- a/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/ChapterCreatedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; - -public class ChapterCreatedEvent : IIntegrationEvent -{ - public required uint ChapterId { get; init; } - public required uint NovelId { get; init; } - public required uint VolumeId { get; init; } - public required int VolumeOrder { get; init; } - public required uint ChapterOrder { get; init; } - public required string ChapterTitle { get; init; } -} diff --git a/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/NovelCreatedEvent.cs b/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/NovelCreatedEvent.cs deleted file mode 100644 index f55c349..0000000 --- a/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/NovelCreatedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FictionArchive.Common.Enums; -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; - -public class NovelCreatedEvent : IIntegrationEvent -{ - public required uint NovelId { get; init; } - public required string Title { get; init; } - public required Language OriginalLanguage { get; init; } - public required string Source { get; init; } - public required string AuthorName { get; init; } -} diff --git a/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/UserInvitedEvent.cs b/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/UserInvitedEvent.cs deleted file mode 100644 index 609f029..0000000 --- a/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/UserInvitedEvent.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; - -public class UserInvitedEvent : IIntegrationEvent -{ - public Guid InvitedUserId { get; set; } - public required string InvitedUsername { get; set; } - public required string InvitedEmail { get; set; } - public required string InvitedOAuthProviderId { get; set; } - - public Guid InviterId { get; set; } - public required string InviterUsername { get; set; } - public required string InviterOAuthProviderId { get; set; } -} diff --git a/FictionArchive.Service.UserNovelDataService/Program.cs b/FictionArchive.Service.UserNovelDataService/Program.cs index d14cc0e..c06317f 100644 --- a/FictionArchive.Service.UserNovelDataService/Program.cs +++ b/FictionArchive.Service.UserNovelDataService/Program.cs @@ -1,11 +1,9 @@ using FictionArchive.Common.Extensions; using FictionArchive.Service.Shared; using FictionArchive.Service.Shared.Extensions; -using FictionArchive.Service.Shared.Services.EventBus.Implementations; +using FictionArchive.Service.UserNovelDataService.Consumers; using FictionArchive.Service.UserNovelDataService.GraphQL; -using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; using FictionArchive.Service.UserNovelDataService.Services; -using FictionArchive.Service.UserNovelDataService.Services.EventHandlers; namespace FictionArchive.Service.UserNovelDataService; @@ -22,17 +20,18 @@ public class Program builder.Services.AddMemoryCache(); builder.Services.AddHealthChecks(); - #region Event Bus + #region MassTransit if (!isSchemaExport) { - builder.Services.AddRabbitMQ(opt => - { - builder.Configuration.GetSection("RabbitMQ").Bind(opt); - }) - .Subscribe() - .Subscribe() - .Subscribe(); + builder.Services.AddFictionArchiveMassTransit( + builder.Configuration, + x => + { + x.AddConsumer(); + x.AddConsumer(); + x.AddConsumer(); + }); } #endregion diff --git a/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/NovelCreatedEventHandler.cs b/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/NovelCreatedEventHandler.cs deleted file mode 100644 index 1e47531..0000000 --- a/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/NovelCreatedEventHandler.cs +++ /dev/null @@ -1,36 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; -using FictionArchive.Service.UserNovelDataService.Models.Database; -using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; -using Microsoft.EntityFrameworkCore; - -namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers; - -public class NovelCreatedEventHandler : IIntegrationEventHandler -{ - private readonly UserNovelDataServiceDbContext _dbContext; - private readonly ILogger _logger; - - public NovelCreatedEventHandler( - UserNovelDataServiceDbContext dbContext, - ILogger logger) - { - _dbContext = dbContext; - _logger = logger; - } - - public async Task Handle(NovelCreatedEvent @event) - { - var exists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId); - if (exists) - { - _logger.LogDebug("Novel {NovelId} already exists, skipping", @event.NovelId); - return; - } - - var novel = new Novel { Id = @event.NovelId }; - _dbContext.Novels.Add(novel); - await _dbContext.SaveChangesAsync(); - - _logger.LogInformation("Created novel stub for {NovelId}", @event.NovelId); - } -} diff --git a/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/UserInvitedEventHandler.cs b/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/UserInvitedEventHandler.cs deleted file mode 100644 index a48a2c8..0000000 --- a/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/UserInvitedEventHandler.cs +++ /dev/null @@ -1,40 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; -using FictionArchive.Service.UserNovelDataService.Models.Database; -using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; -using Microsoft.EntityFrameworkCore; - -namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers; - -public class UserInvitedEventHandler : IIntegrationEventHandler -{ - private readonly UserNovelDataServiceDbContext _dbContext; - private readonly ILogger _logger; - - public UserInvitedEventHandler( - UserNovelDataServiceDbContext dbContext, - ILogger logger) - { - _dbContext = dbContext; - _logger = logger; - } - - public async Task Handle(UserInvitedEvent @event) - { - var exists = await _dbContext.Users.AnyAsync(u => u.Id == @event.InvitedUserId); - if (exists) - { - _logger.LogDebug("User {UserId} already exists, skipping", @event.InvitedUserId); - return; - } - - var user = new User - { - Id = @event.InvitedUserId, - OAuthProviderId = @event.InvitedOAuthProviderId - }; - _dbContext.Users.Add(user); - await _dbContext.SaveChangesAsync(); - - _logger.LogInformation("Created user stub for {UserId}", @event.InvitedUserId); - } -} diff --git a/FictionArchive.Service.UserNovelDataService/appsettings.json b/FictionArchive.Service.UserNovelDataService/appsettings.json index 425535a..7a9a338 100644 --- a/FictionArchive.Service.UserNovelDataService/appsettings.json +++ b/FictionArchive.Service.UserNovelDataService/appsettings.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" } }, "ConnectionStrings": { diff --git a/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs b/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs index 9050d79..9233141 100644 --- a/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs +++ b/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs @@ -1,9 +1,9 @@ -using FictionArchive.Service.Shared.Services.EventBus; using FictionArchive.Service.UserService.Models.Database; using FictionArchive.Service.UserService.Services; using FictionArchive.Service.UserService.Services.AuthenticationClient; using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; using FluentAssertions; +using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -27,13 +27,13 @@ public class UserManagementServiceTests private static UserManagementService CreateService( UserServiceDbContext dbContext, IAuthenticationServiceClient authClient, - IEventBus? eventBus = null) + IPublishEndpoint? publishEndpoint = null) { return new UserManagementService( dbContext, NullLogger.Instance, authClient, - eventBus ?? Substitute.For()); + publishEndpoint ?? Substitute.For()); } private static User CreateTestUser(string username, string email, int availableInvites = 5) diff --git a/FictionArchive.Service.UserService/Contracts/UserInvited.cs b/FictionArchive.Service.UserService/Contracts/UserInvited.cs new file mode 100644 index 0000000..3cf108b --- /dev/null +++ b/FictionArchive.Service.UserService/Contracts/UserInvited.cs @@ -0,0 +1,12 @@ +using FictionArchive.Service.Shared.Contracts.Events; + +namespace FictionArchive.Service.UserService.Contracts; + +public record UserInvited( + string InvitedUserId, + string InvitedUsername, + string InvitedEmail, + string InvitedOAuthProviderId, + string InviterId, + string InviterUsername, + string InviterOAuthProviderId) : IUserInvited; diff --git a/FictionArchive.Service.UserService/Models/IntegrationEvents/UserInvitedEvent.cs b/FictionArchive.Service.UserService/Models/IntegrationEvents/UserInvitedEvent.cs deleted file mode 100644 index 5039e75..0000000 --- a/FictionArchive.Service.UserService/Models/IntegrationEvents/UserInvitedEvent.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.UserService.Models.IntegrationEvents; - -public class UserInvitedEvent : IIntegrationEvent -{ - // Invited user info - public Guid InvitedUserId { get; set; } - public required string InvitedUsername { get; set; } - public required string InvitedEmail { get; set; } - public required string InvitedOAuthProviderId { get; set; } - - // Inviter info - public Guid InviterId { get; set; } - public required string InviterUsername { get; set; } - public required string InviterOAuthProviderId { get; set; } -} diff --git a/FictionArchive.Service.UserService/Program.cs b/FictionArchive.Service.UserService/Program.cs index 419c199..cdbfb84 100644 --- a/FictionArchive.Service.UserService/Program.cs +++ b/FictionArchive.Service.UserService/Program.cs @@ -2,7 +2,6 @@ using System.Net.Http.Headers; using FictionArchive.Common.Extensions; using FictionArchive.Service.Shared; using FictionArchive.Service.Shared.Extensions; -using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.UserService.GraphQL; using FictionArchive.Service.UserService.Services; using FictionArchive.Service.UserService.Services.AuthenticationClient; @@ -19,15 +18,9 @@ public class Program var builder = WebApplication.CreateBuilder(args); builder.AddLocalAppsettings(); - #region Event Bus + #region MassTransit - if (!isSchemaExport) - { - builder.Services.AddRabbitMQ(opt => - { - builder.Configuration.GetSection("RabbitMQ").Bind(opt); - }); - } + builder.Services.AddFictionArchiveMassTransit(builder.Configuration); #endregion diff --git a/FictionArchive.Service.UserService/Services/UserManagementService.cs b/FictionArchive.Service.UserService/Services/UserManagementService.cs index 6a5a496..b690689 100644 --- a/FictionArchive.Service.UserService/Services/UserManagementService.cs +++ b/FictionArchive.Service.UserService/Services/UserManagementService.cs @@ -1,7 +1,8 @@ -using FictionArchive.Service.Shared.Services.EventBus; +using FictionArchive.Service.Shared.Contracts.Events; +using FictionArchive.Service.UserService.Contracts; using FictionArchive.Service.UserService.Models.Database; -using FictionArchive.Service.UserService.Models.IntegrationEvents; using FictionArchive.Service.UserService.Services.AuthenticationClient; +using MassTransit; using Microsoft.EntityFrameworkCore; namespace FictionArchive.Service.UserService.Services; @@ -11,18 +12,18 @@ public class UserManagementService private readonly ILogger _logger; private readonly UserServiceDbContext _dbContext; private readonly IAuthenticationServiceClient _authClient; - private readonly IEventBus _eventBus; + private readonly IPublishEndpoint _publishEndpoint; public UserManagementService( UserServiceDbContext dbContext, ILogger logger, IAuthenticationServiceClient authClient, - IEventBus eventBus) + IPublishEndpoint publishEndpoint) { _dbContext = dbContext; _logger = logger; _authClient = authClient; - _eventBus = eventBus; + _publishEndpoint = publishEndpoint; } /// @@ -99,16 +100,14 @@ public class UserManagementService await _dbContext.SaveChangesAsync(); - await _eventBus.Publish(new UserInvitedEvent - { - InvitedUserId = newUser.Id, - InvitedUsername = newUser.Username, - InvitedEmail = newUser.Email, - InvitedOAuthProviderId = newUser.OAuthProviderId, - InviterId = inviter.Id, - InviterUsername = inviter.Username, - InviterOAuthProviderId = inviter.OAuthProviderId - }); + await _publishEndpoint.Publish(new UserInvited( + InvitedUserId: newUser.Id.ToString(), + InvitedUsername: newUser.Username, + InvitedEmail: newUser.Email, + InvitedOAuthProviderId: newUser.OAuthProviderId, + InviterId: inviter.Id.ToString(), + InviterUsername: inviter.Username, + InviterOAuthProviderId: inviter.OAuthProviderId)); _logger.LogInformation( "User {Username} was successfully invited by {InviterId}. New user id: {NewUserId}", diff --git a/FictionArchive.Service.UserService/appsettings.json b/FictionArchive.Service.UserService/appsettings.json index 2182f2f..b5fa583 100644 --- a/FictionArchive.Service.UserService/appsettings.json +++ b/FictionArchive.Service.UserService/appsettings.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" } }, "ConnectionStrings": { diff --git a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts index 1a5ffd9..1f5a4da 100644 --- a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts +++ b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts @@ -80,7 +80,7 @@ export type ChapterDtoFilterInput = { url?: InputMaybe; }; -export type ChapterPullRequestedEvent = { +export type ChapterPullRequested = { chapterOrder: Scalars['UnsignedInt']['output']; novelId: Scalars['UnsignedInt']['output']; volumeId: Scalars['UnsignedInt']['output']; @@ -168,7 +168,7 @@ export type FetchChapterContentsInput = { }; export type FetchChapterContentsPayload = { - chapterPullRequestedEvent: Maybe; + chapterPullRequested: Maybe; }; export type FormatError = Error & { @@ -203,7 +203,7 @@ export type ImportNovelInput = { }; export type ImportNovelPayload = { - novelUpdateRequestedEvent: Maybe; + novelUpdateRequested: Maybe; }; export type InstantFilterInput = { @@ -499,7 +499,7 @@ export type NovelTagDtoFilterInput = { tagType?: InputMaybe; }; -export type NovelUpdateRequestedEvent = { +export type NovelUpdateRequested = { novelUrl: Scalars['String']['output']; }; @@ -1010,7 +1010,7 @@ export type ImportNovelMutationVariables = Exact<{ }>; -export type ImportNovelMutation = { importNovel: { novelUpdateRequestedEvent: { novelUrl: string } | null } }; +export type ImportNovelMutation = { importNovel: { novelUpdateRequested: { novelUrl: string } | null } }; export type InviteUserMutationVariables = Exact<{ input: InviteUserInput; @@ -1114,7 +1114,7 @@ export const AddToReadingListDocument = {"kind":"Document","definitions":[{"kind export const CreateReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boolean"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUpdateRequestedEvent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUpdateRequested"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}}]}}]}}]} as unknown as DocumentNode; export const InviteUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InviteUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InviteUserInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userDto"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InvalidOperationError"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const RemoveBookmarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveBookmark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveBookmarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeBookmark"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarkPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const RemoveFromReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveFromReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveFromReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeFromReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql b/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql index 887afa3..83ea3b2 100644 --- a/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql +++ b/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql @@ -1,6 +1,6 @@ mutation ImportNovel($input: ImportNovelInput!) { importNovel(input: $input) { - novelUpdateRequestedEvent { + novelUpdateRequested { novelUrl } } -- 2.49.1 From ec967770d3b559a556aa9571a50d9c03909a8713 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 28 Jan 2026 12:11:06 -0500 Subject: [PATCH 2/2] [FA-misc] Saga seems to work, fixed a UserNovelDataService bug --- .../FileUploadRequestCreatedConsumer.cs | 3 +- .../FileUploadRequestStatusUpdate.cs | 10 - ...nArchive.Service.NovelService.Tests.csproj | 2 + .../NovelUpdateServiceTests.cs | 17 +- .../Sagas/NovelImportSagaTests.cs | 95 +++ .../Consumers/ChapterPullRequestedConsumer.cs | 13 +- .../Consumers/NovelImportCompletedConsumer.cs | 43 ++ .../Consumers/NovelImportRequestedConsumer.cs | 29 + .../Consumers/NovelUpdateRequestedConsumer.cs | 26 - .../Contracts/ChapterPullRequested.cs | 8 - .../Contracts/FileUploadRequestCreated.cs | 8 - .../Contracts/NovelUpdateRequested.cs | 6 - ...FictionArchive.Service.NovelService.csproj | 3 +- .../GraphQL/Mutation.cs | 7 +- ...60127161500_AddNovelImportSaga.Designer.cs | 673 ++++++++++++++++++ .../20260127161500_AddNovelImportSaga.cs | 76 ++ .../NovelServiceDbContextModelSnapshot.cs | 68 ++ .../Models/ActiveImport.cs | 10 + .../Program.cs | 21 +- .../Sagas/NovelImportSaga.cs | 135 ++++ .../Sagas/NovelImportSagaState.cs | 29 + .../Services/NovelServiceDbContext.cs | 17 + .../Services/NovelUpdateService.cs | 60 +- .../Contracts/Events/IChapterPullCompleted.cs | 10 + .../Contracts/Events/IChapterPullRequested.cs | 3 + .../Events/IFileUploadRequestCreated.cs | 3 + .../Events/IFileUploadRequestStatusUpdate.cs | 3 + .../Contracts/Events/INovelImportCompleted.cs | 11 + .../Contracts/Events/INovelImportRequested.cs | 9 + .../Events/INovelMetadataImported.cs | 10 + .../Contracts/Events/INovelUpdateRequested.cs | 6 - .../Consumers/ChapterCreatedConsumer.cs | 4 +- .../src/lib/graphql/__generated__/graphql.ts | 17 +- .../lib/graphql/mutations/importNovel.graphql | 3 +- 34 files changed, 1341 insertions(+), 97 deletions(-) delete mode 100644 FictionArchive.Service.FileService/Contracts/FileUploadRequestStatusUpdate.cs create mode 100644 FictionArchive.Service.NovelService.Tests/Sagas/NovelImportSagaTests.cs create mode 100644 FictionArchive.Service.NovelService/Consumers/NovelImportCompletedConsumer.cs create mode 100644 FictionArchive.Service.NovelService/Consumers/NovelImportRequestedConsumer.cs delete mode 100644 FictionArchive.Service.NovelService/Consumers/NovelUpdateRequestedConsumer.cs delete mode 100644 FictionArchive.Service.NovelService/Contracts/ChapterPullRequested.cs delete mode 100644 FictionArchive.Service.NovelService/Contracts/FileUploadRequestCreated.cs delete mode 100644 FictionArchive.Service.NovelService/Contracts/NovelUpdateRequested.cs create mode 100644 FictionArchive.Service.NovelService/Migrations/20260127161500_AddNovelImportSaga.Designer.cs create mode 100644 FictionArchive.Service.NovelService/Migrations/20260127161500_AddNovelImportSaga.cs create mode 100644 FictionArchive.Service.NovelService/Models/ActiveImport.cs create mode 100644 FictionArchive.Service.NovelService/Sagas/NovelImportSaga.cs create mode 100644 FictionArchive.Service.NovelService/Sagas/NovelImportSagaState.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/IChapterPullCompleted.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/INovelImportCompleted.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/INovelImportRequested.cs create mode 100644 FictionArchive.Service.Shared/Contracts/Events/INovelMetadataImported.cs delete mode 100644 FictionArchive.Service.Shared/Contracts/Events/INovelUpdateRequested.cs diff --git a/FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs b/FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs index 0825358..37fb0c5 100644 --- a/FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs +++ b/FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs @@ -1,7 +1,6 @@ using Amazon.S3; using Amazon.S3.Model; using FictionArchive.Common.Enums; -using FictionArchive.Service.FileService.Contracts; using FictionArchive.Service.FileService.Models; using FictionArchive.Service.Shared.Contracts.Events; using MassTransit; @@ -53,6 +52,7 @@ public class FileUploadRequestCreatedConsumer : IConsumer( new FileUploadRequestStatusUpdate( + ImportId: message.ImportId, RequestId: message.RequestId, Status: RequestStatus.Failed, FileAccessUrl: null, @@ -66,6 +66,7 @@ public class FileUploadRequestCreatedConsumer : IConsumer( new FileUploadRequestStatusUpdate( + ImportId: message.ImportId, RequestId: message.RequestId, Status: RequestStatus.Success, FileAccessUrl: fileAccessUrl, diff --git a/FictionArchive.Service.FileService/Contracts/FileUploadRequestStatusUpdate.cs b/FictionArchive.Service.FileService/Contracts/FileUploadRequestStatusUpdate.cs deleted file mode 100644 index dcb1eef..0000000 --- a/FictionArchive.Service.FileService/Contracts/FileUploadRequestStatusUpdate.cs +++ /dev/null @@ -1,10 +0,0 @@ -using FictionArchive.Common.Enums; -using FictionArchive.Service.Shared.Contracts.Events; - -namespace FictionArchive.Service.FileService.Contracts; - -public record FileUploadRequestStatusUpdate( - Guid RequestId, - RequestStatus Status, - string? FileAccessUrl, - string? ErrorMessage) : IFileUploadRequestStatusUpdate; diff --git a/FictionArchive.Service.NovelService.Tests/FictionArchive.Service.NovelService.Tests.csproj b/FictionArchive.Service.NovelService.Tests/FictionArchive.Service.NovelService.Tests.csproj index 24e3242..5bf6a8c 100644 --- a/FictionArchive.Service.NovelService.Tests/FictionArchive.Service.NovelService.Tests.csproj +++ b/FictionArchive.Service.NovelService.Tests/FictionArchive.Service.NovelService.Tests.csproj @@ -9,8 +9,10 @@ + + diff --git a/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs index 903d450..8193cca 100644 --- a/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs +++ b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs @@ -14,6 +14,7 @@ using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using NodaTime; using NSubstitute; using Xunit; @@ -80,7 +81,10 @@ public class NovelUpdateServiceTests PendingImageUrl = pendingImageUrl }); - return new NovelUpdateService(dbContext, NullLogger.Instance, new[] { adapter }, publishEndpoint, options); + var clock = Substitute.For(); + clock.GetCurrentInstant().Returns(Instant.FromUnixTimeSeconds(0)); + + return new NovelUpdateService(dbContext, NullLogger.Instance, new[] { adapter }, publishEndpoint, options, clock); } [Fact] @@ -110,8 +114,10 @@ public class NovelUpdateServiceTests var pendingImageUrl = "https://pending/placeholder.jpg"; var service = CreateService(dbContext, adapter, publishEndpoint, pendingImageUrl); - var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order); + var importId = Guid.NewGuid(); + var (updatedChapter, imageCount) = await service.PullChapterContents(importId, novel.Id, volume.Id, chapter.Order); + imageCount.Should().Be(2); updatedChapter.Images.Should().HaveCount(2); updatedChapter.Images.Select(i => i.OriginalPath).Should().BeEquivalentTo(new[] { image1.Url, image2.Url }); updatedChapter.Images.All(i => i.Id != Guid.Empty).Should().BeTrue(); @@ -128,9 +134,10 @@ public class NovelUpdateServiceTests .BeEquivalentTo(updatedChapter.Images.Select(img => img.Id.ToString())); publishedEvents.Should().HaveCount(2); + publishedEvents.Should().OnlyContain(e => e.ImportId == importId); publishedEvents.Select(e => e.RequestId).Should().BeEquivalentTo(updatedChapter.Images.Select(i => i.Id)); publishedEvents.Select(e => e.FileData).Should().BeEquivalentTo(new[] { image1.Data, image2.Data }); - publishedEvents.Should().OnlyContain(e => e.FilePath.StartsWith($"{novel.Id}/Images/Chapter-{updatedChapter.Id}/")); + publishedEvents.Should().OnlyContain(e => e.FilePath.StartsWith($"Novels/{novel.Id}/Images/Chapter-{updatedChapter.Id}/")); } [Fact] @@ -155,8 +162,10 @@ public class NovelUpdateServiceTests var service = CreateService(dbContext, adapter, publishEndpoint); - var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order); + var importId = Guid.NewGuid(); + var (updatedChapter, imageCount) = await service.PullChapterContents(importId, novel.Id, volume.Id, chapter.Order); + imageCount.Should().Be(1); var storedHtml = updatedChapter.Body.Texts.Single().Text; var doc = new HtmlDocument(); doc.LoadHtml(storedHtml); diff --git a/FictionArchive.Service.NovelService.Tests/Sagas/NovelImportSagaTests.cs b/FictionArchive.Service.NovelService.Tests/Sagas/NovelImportSagaTests.cs new file mode 100644 index 0000000..802b214 --- /dev/null +++ b/FictionArchive.Service.NovelService.Tests/Sagas/NovelImportSagaTests.cs @@ -0,0 +1,95 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.NovelService.Sagas; +using FictionArchive.Service.Shared.Contracts.Events; +using FluentAssertions; +using MassTransit; +using MassTransit.Testing; +using Microsoft.Extensions.DependencyInjection; +using NodaTime; +using NodaTime.Testing; +using Xunit; + +namespace FictionArchive.Service.NovelService.Tests.Sagas; + +public class NovelImportSagaTests +{ + private readonly FakeClock _clock = new(Instant.FromUtc(2026, 1, 27, 12, 0, 0)); + + [Fact] + public async Task Should_transition_to_importing_on_import_requested() + { + await using var provider = CreateTestProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + var importId = Guid.NewGuid(); + await harness.Bus.Publish(new NovelImportRequested(importId, "https://example.com/novel")); + + var sagaHarness = harness.GetSagaStateMachineHarness(); + (await sagaHarness.Exists(importId, x => x.Importing)).HasValue.Should().BeTrue(); + } + + [Fact] + public async Task Should_transition_to_completed_when_no_chapters() + { + await using var provider = CreateTestProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + var importId = Guid.NewGuid(); + await harness.Bus.Publish(new NovelImportRequested(importId, "https://example.com/novel")); + await harness.Bus.Publish(new NovelMetadataImported(importId, 1, 0)); + + var sagaHarness = harness.GetSagaStateMachineHarness(); + (await sagaHarness.Exists(importId, x => x.Completed)).HasValue.Should().BeTrue(); + + (await harness.Published.Any(x => + x.Context.Message.ImportId == importId && x.Context.Message.Success)).Should().BeTrue(); + } + + [Fact] + public async Task Should_transition_to_processing_when_chapters_pending() + { + await using var provider = CreateTestProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + var importId = Guid.NewGuid(); + await harness.Bus.Publish(new NovelImportRequested(importId, "https://example.com/novel")); + await harness.Bus.Publish(new NovelMetadataImported(importId, 1, 2)); + + var sagaHarness = harness.GetSagaStateMachineHarness(); + (await sagaHarness.Exists(importId, x => x.Processing)).HasValue.Should().BeTrue(); + } + + [Fact] + public async Task Should_complete_when_all_chapters_pulled_and_images_uploaded() + { + await using var provider = CreateTestProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + var importId = Guid.NewGuid(); + await harness.Bus.Publish(new NovelImportRequested(importId, "https://example.com/novel")); + await harness.Bus.Publish(new NovelMetadataImported(importId, 1, 2)); + await harness.Bus.Publish(new ChapterPullCompleted(importId, 1, 1)); + await harness.Bus.Publish(new ChapterPullCompleted(importId, 2, 0)); + await harness.Bus.Publish(new FileUploadRequestStatusUpdate( + importId, Guid.NewGuid(), RequestStatus.Success, "https://cdn.example.com/image.jpg", null)); + + var sagaHarness = harness.GetSagaStateMachineHarness(); + (await sagaHarness.Exists(importId, x => x.Completed)).HasValue.Should().BeTrue(); + } + + private ServiceProvider CreateTestProvider() + { + return new ServiceCollection() + .AddSingleton(_clock) + .AddMassTransitTestHarness(cfg => + { + cfg.AddSagaStateMachine() + .InMemoryRepository(); + }) + .BuildServiceProvider(true); + } +} diff --git a/FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs b/FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs index 117124d..7bed18e 100644 --- a/FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs +++ b/FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs @@ -21,6 +21,17 @@ public class ChapterPullRequestedConsumer : IConsumer public async Task Consume(ConsumeContext context) { var message = context.Message; - await _novelUpdateService.PullChapterContents(message.NovelId, message.VolumeId, message.ChapterOrder); + + var (chapter, imageCount) = await _novelUpdateService.PullChapterContents( + message.ImportId, + message.NovelId, + message.VolumeId, + message.ChapterOrder); + + await context.Publish(new ChapterPullCompleted( + message.ImportId, + chapter.Id, + imageCount + )); } } diff --git a/FictionArchive.Service.NovelService/Consumers/NovelImportCompletedConsumer.cs b/FictionArchive.Service.NovelService/Consumers/NovelImportCompletedConsumer.cs new file mode 100644 index 0000000..0deae08 --- /dev/null +++ b/FictionArchive.Service.NovelService/Consumers/NovelImportCompletedConsumer.cs @@ -0,0 +1,43 @@ +using FictionArchive.Service.NovelService.Services; +using FictionArchive.Service.Shared.Contracts.Events; +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace FictionArchive.Service.NovelService.Consumers; + +public class NovelImportCompletedConsumer : IConsumer +{ + private readonly ILogger _logger; + private readonly NovelServiceDbContext _dbContext; + + public NovelImportCompletedConsumer( + ILogger logger, + NovelServiceDbContext dbContext) + { + _logger = logger; + _dbContext = dbContext; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + _logger.LogInformation( + "Novel import {ImportId} completed. Success: {Success}, NovelId: {NovelId}, Error: {Error}", + message.ImportId, + message.Success, + message.NovelId, + message.ErrorMessage); + + // Remove from ActiveImports to allow future imports + var activeImport = await _dbContext.ActiveImports + .FirstOrDefaultAsync(a => a.ImportId == message.ImportId); + + if (activeImport != null) + { + _dbContext.ActiveImports.Remove(activeImport); + await _dbContext.SaveChangesAsync(); + } + } +} diff --git a/FictionArchive.Service.NovelService/Consumers/NovelImportRequestedConsumer.cs b/FictionArchive.Service.NovelService/Consumers/NovelImportRequestedConsumer.cs new file mode 100644 index 0000000..bb85841 --- /dev/null +++ b/FictionArchive.Service.NovelService/Consumers/NovelImportRequestedConsumer.cs @@ -0,0 +1,29 @@ +using FictionArchive.Service.NovelService.Services; +using FictionArchive.Service.Shared.Contracts.Events; +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace FictionArchive.Service.NovelService.Consumers; + +public class NovelImportRequestedConsumer : IConsumer +{ + private readonly ILogger _logger; + private readonly NovelUpdateService _novelUpdateService; + + public NovelImportRequestedConsumer( + ILogger logger, + NovelUpdateService novelUpdateService) + { + _logger = logger; + _novelUpdateService = novelUpdateService; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + _logger.LogInformation("Starting novel import for {NovelUrl} with ImportId {ImportId}", + message.NovelUrl, message.ImportId); + + await _novelUpdateService.ImportNovel(message.ImportId, message.NovelUrl); + } +} diff --git a/FictionArchive.Service.NovelService/Consumers/NovelUpdateRequestedConsumer.cs b/FictionArchive.Service.NovelService/Consumers/NovelUpdateRequestedConsumer.cs deleted file mode 100644 index d09ccec..0000000 --- a/FictionArchive.Service.NovelService/Consumers/NovelUpdateRequestedConsumer.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FictionArchive.Service.NovelService.Services; -using FictionArchive.Service.Shared.Contracts.Events; -using MassTransit; -using Microsoft.Extensions.Logging; - -namespace FictionArchive.Service.NovelService.Consumers; - -public class NovelUpdateRequestedConsumer : IConsumer -{ - private readonly ILogger _logger; - private readonly NovelUpdateService _novelUpdateService; - - public NovelUpdateRequestedConsumer( - ILogger logger, - NovelUpdateService novelUpdateService) - { - _logger = logger; - _novelUpdateService = novelUpdateService; - } - - public async Task Consume(ConsumeContext context) - { - var message = context.Message; - await _novelUpdateService.ImportNovel(message.NovelUrl); - } -} diff --git a/FictionArchive.Service.NovelService/Contracts/ChapterPullRequested.cs b/FictionArchive.Service.NovelService/Contracts/ChapterPullRequested.cs deleted file mode 100644 index 994975e..0000000 --- a/FictionArchive.Service.NovelService/Contracts/ChapterPullRequested.cs +++ /dev/null @@ -1,8 +0,0 @@ -using FictionArchive.Service.Shared.Contracts.Events; - -namespace FictionArchive.Service.NovelService.Contracts; - -public record ChapterPullRequested( - uint NovelId, - uint VolumeId, - uint ChapterOrder) : IChapterPullRequested; diff --git a/FictionArchive.Service.NovelService/Contracts/FileUploadRequestCreated.cs b/FictionArchive.Service.NovelService/Contracts/FileUploadRequestCreated.cs deleted file mode 100644 index b1ecdb2..0000000 --- a/FictionArchive.Service.NovelService/Contracts/FileUploadRequestCreated.cs +++ /dev/null @@ -1,8 +0,0 @@ -using FictionArchive.Service.Shared.Contracts.Events; - -namespace FictionArchive.Service.NovelService.Contracts; - -public record FileUploadRequestCreated( - Guid RequestId, - string FilePath, - byte[] FileData) : IFileUploadRequestCreated; diff --git a/FictionArchive.Service.NovelService/Contracts/NovelUpdateRequested.cs b/FictionArchive.Service.NovelService/Contracts/NovelUpdateRequested.cs deleted file mode 100644 index 2fe8887..0000000 --- a/FictionArchive.Service.NovelService/Contracts/NovelUpdateRequested.cs +++ /dev/null @@ -1,6 +0,0 @@ -using FictionArchive.Service.Shared.Contracts.Events; - -namespace FictionArchive.Service.NovelService.Contracts; - -public record NovelUpdateRequested( - string NovelUrl) : INovelUpdateRequested; diff --git a/FictionArchive.Service.NovelService/FictionArchive.Service.NovelService.csproj b/FictionArchive.Service.NovelService/FictionArchive.Service.NovelService.csproj index 1d90435..3d1a9b9 100644 --- a/FictionArchive.Service.NovelService/FictionArchive.Service.NovelService.csproj +++ b/FictionArchive.Service.NovelService/FictionArchive.Service.NovelService.csproj @@ -10,11 +10,12 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/FictionArchive.Service.NovelService/GraphQL/Mutation.cs b/FictionArchive.Service.NovelService/GraphQL/Mutation.cs index d5163b4..16ae407 100644 --- a/FictionArchive.Service.NovelService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.NovelService/GraphQL/Mutation.cs @@ -5,6 +5,7 @@ using FictionArchive.Service.NovelService.Models.Novels; using FictionArchive.Service.NovelService.Models.SourceAdapters; using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services.SourceAdapters; +using FictionArchive.Service.Shared.Contracts.Events; using HotChocolate.Authorization; using HotChocolate.Types; using Microsoft.EntityFrameworkCore; @@ -13,20 +14,22 @@ namespace FictionArchive.Service.NovelService.GraphQL; public class Mutation { + [Error] [Authorize] - public async Task ImportNovel(string novelUrl, NovelUpdateService service) + public async Task ImportNovel(string novelUrl, NovelUpdateService service) { return await service.QueueNovelImport(novelUrl); } [Authorize] public async Task FetchChapterContents( + Guid importId, uint novelId, uint volumeId, uint chapterOrder, NovelUpdateService service) { - return await service.QueueChapterPull(novelId, volumeId, chapterOrder); + return await service.QueueChapterPull(importId, novelId, volumeId, chapterOrder); } [Error] diff --git a/FictionArchive.Service.NovelService/Migrations/20260127161500_AddNovelImportSaga.Designer.cs b/FictionArchive.Service.NovelService/Migrations/20260127161500_AddNovelImportSaga.Designer.cs new file mode 100644 index 0000000..cf54361 --- /dev/null +++ b/FictionArchive.Service.NovelService/Migrations/20260127161500_AddNovelImportSaga.Designer.cs @@ -0,0 +1,673 @@ +// +using System; +using FictionArchive.Service.NovelService.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FictionArchive.Service.NovelService.Migrations +{ + [DbContext(typeof(NovelServiceDbContext))] + [Migration("20260127161500_AddNovelImportSaga")] + partial class AddNovelImportSaga + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.ActiveImport", b => + { + b.Property("ImportId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("NovelUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ImportId"); + + b.HasIndex("NovelUrl") + .IsUnique(); + + b.ToTable("ActiveImports"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChapterId") + .HasColumnType("bigint"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NewPath") + .HasColumnType("text"); + + b.Property("OriginalPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("LocalizationKeys"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EngineId") + .HasColumnType("bigint"); + + b.Property("KeyRequestedForTranslationId") + .HasColumnType("uuid"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TranslateTo") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EngineId"); + + b.HasIndex("KeyRequestedForTranslationId"); + + b.ToTable("LocalizationRequests"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Language") + .HasColumnType("integer"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LocalizationKeyId") + .HasColumnType("uuid"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.Property("TranslationEngineId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("LocalizationKeyId"); + + b.HasIndex("TranslationEngineId"); + + b.ToTable("LocalizationText"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BodyId") + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("VolumeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BodyId"); + + b.HasIndex("NameId"); + + b.HasIndex("VolumeId", "Order") + .IsUnique(); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("CoverImageId") + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DescriptionId") + .HasColumnType("uuid"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.Property("RawLanguage") + .HasColumnType("integer"); + + b.Property("RawStatus") + .HasColumnType("integer"); + + b.Property("SourceId") + .HasColumnType("bigint"); + + b.Property("StatusOverride") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("CoverImageId"); + + b.HasIndex("DescriptionId"); + + b.HasIndex("NameId"); + + b.HasIndex("SourceId"); + + b.HasIndex("ExternalId", "SourceId") + .IsUnique(); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayNameId") + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .HasColumnType("bigint"); + + b.Property("TagType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DisplayNameId"); + + b.HasIndex("SourceId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalUrl") + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NameId"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Source", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sources"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("TranslationEngines"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("Order") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("NameId"); + + b.HasIndex("NovelId", "Order") + .IsUnique(); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Sagas.NovelImportSagaState", b => + { + b.Property("CorrelationId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedChapters") + .HasColumnType("integer"); + + b.Property("CompletedImages") + .HasColumnType("integer"); + + b.Property("CurrentState") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("ExpectedChapters") + .HasColumnType("integer"); + + b.Property("ExpectedImages") + .HasColumnType("integer"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("NovelUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.HasIndex("CurrentState"); + + b.HasIndex("NovelUrl"); + + b.ToTable("NovelImportSagaStates"); + }); + + modelBuilder.Entity("NovelNovelTag", b => + { + b.Property("NovelsId") + .HasColumnType("bigint"); + + b.Property("TagsId") + .HasColumnType("bigint"); + + b.HasKey("NovelsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("NovelNovelTag"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Chapter", "Chapter") + .WithMany("Images") + .HasForeignKey("ChapterId"); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine") + .WithMany() + .HasForeignKey("EngineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "KeyRequestedForTranslation") + .WithMany() + .HasForeignKey("KeyRequestedForTranslationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Engine"); + + b.Navigation("KeyRequestedForTranslation"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", null) + .WithMany("Texts") + .HasForeignKey("LocalizationKeyId"); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "TranslationEngine") + .WithMany() + .HasForeignKey("TranslationEngineId"); + + b.Navigation("TranslationEngine"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Body") + .WithMany() + .HasForeignKey("BodyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Body"); + + b.Navigation("Name"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Person", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Images.Image", "CoverImage") + .WithMany() + .HasForeignKey("CoverImageId"); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Description") + .WithMany() + .HasForeignKey("DescriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source") + .WithMany() + .HasForeignKey("SourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("CoverImage"); + + b.Navigation("Description"); + + b.Navigation("Name"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "DisplayName") + .WithMany() + .HasForeignKey("DisplayNameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source") + .WithMany() + .HasForeignKey("SourceId"); + + b.Navigation("DisplayName"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Name"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel") + .WithMany("Volumes") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Name"); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("NovelNovelTag", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null) + .WithMany() + .HasForeignKey("NovelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.NovelTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b => + { + b.Navigation("Texts"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FictionArchive.Service.NovelService/Migrations/20260127161500_AddNovelImportSaga.cs b/FictionArchive.Service.NovelService/Migrations/20260127161500_AddNovelImportSaga.cs new file mode 100644 index 0000000..70e0f2e --- /dev/null +++ b/FictionArchive.Service.NovelService/Migrations/20260127161500_AddNovelImportSaga.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace FictionArchive.Service.NovelService.Migrations +{ + /// + public partial class AddNovelImportSaga : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ActiveImports", + columns: table => new + { + ImportId = table.Column(type: "uuid", nullable: false), + NovelUrl = table.Column(type: "text", nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ActiveImports", x => x.ImportId); + }); + + migrationBuilder.CreateTable( + name: "NovelImportSagaStates", + columns: table => new + { + CorrelationId = table.Column(type: "uuid", nullable: false), + CurrentState = table.Column(type: "text", nullable: false), + NovelUrl = table.Column(type: "text", nullable: false), + NovelId = table.Column(type: "bigint", nullable: true), + ExpectedChapters = table.Column(type: "integer", nullable: false), + CompletedChapters = table.Column(type: "integer", nullable: false), + ExpectedImages = table.Column(type: "integer", nullable: false), + CompletedImages = table.Column(type: "integer", nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + ErrorMessage = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_NovelImportSagaStates", x => x.CorrelationId); + }); + + migrationBuilder.CreateIndex( + name: "IX_ActiveImports_NovelUrl", + table: "ActiveImports", + column: "NovelUrl", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_NovelImportSagaStates_CurrentState", + table: "NovelImportSagaStates", + column: "CurrentState"); + + migrationBuilder.CreateIndex( + name: "IX_NovelImportSagaStates_NovelUrl", + table: "NovelImportSagaStates", + column: "NovelUrl"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ActiveImports"); + + migrationBuilder.DropTable( + name: "NovelImportSagaStates"); + } + } +} diff --git a/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs b/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs index f4c8aea..25dc598 100644 --- a/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs +++ b/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs @@ -23,6 +23,27 @@ namespace FictionArchive.Service.NovelService.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.ActiveImport", b => + { + b.Property("ImportId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("NovelUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ImportId"); + + b.HasIndex("NovelUrl") + .IsUnique(); + + b.ToTable("ActiveImports"); + }); + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b => { b.Property("Id") @@ -391,6 +412,53 @@ namespace FictionArchive.Service.NovelService.Migrations b.ToTable("Volume"); }); + modelBuilder.Entity("FictionArchive.Service.NovelService.Sagas.NovelImportSagaState", b => + { + b.Property("CorrelationId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedChapters") + .HasColumnType("integer"); + + b.Property("CompletedImages") + .HasColumnType("integer"); + + b.Property("CurrentState") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("ExpectedChapters") + .HasColumnType("integer"); + + b.Property("ExpectedImages") + .HasColumnType("integer"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("NovelUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.HasIndex("CurrentState"); + + b.HasIndex("NovelUrl"); + + b.ToTable("NovelImportSagaStates"); + }); + modelBuilder.Entity("NovelNovelTag", b => { b.Property("NovelsId") diff --git a/FictionArchive.Service.NovelService/Models/ActiveImport.cs b/FictionArchive.Service.NovelService/Models/ActiveImport.cs new file mode 100644 index 0000000..4e15a8c --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/ActiveImport.cs @@ -0,0 +1,10 @@ +using NodaTime; + +namespace FictionArchive.Service.NovelService.Models; + +public class ActiveImport +{ + public Guid ImportId { get; set; } + public required string NovelUrl { get; set; } + public Instant StartedAt { get; set; } +} diff --git a/FictionArchive.Service.NovelService/Program.cs b/FictionArchive.Service.NovelService/Program.cs index 95af51f..ee01a99 100644 --- a/FictionArchive.Service.NovelService/Program.cs +++ b/FictionArchive.Service.NovelService/Program.cs @@ -2,13 +2,16 @@ using FictionArchive.Common.Extensions; using FictionArchive.Service.NovelService.Consumers; using FictionArchive.Service.NovelService.GraphQL; using FictionArchive.Service.NovelService.Models.Configuration; +using FictionArchive.Service.NovelService.Sagas; using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services.SourceAdapters; using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia; using FictionArchive.Service.Shared; using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Services.GraphQL; +using MassTransit; using Microsoft.EntityFrameworkCore; +using NodaTime; namespace FictionArchive.Service.NovelService; @@ -33,8 +36,17 @@ public class Program { x.AddConsumer(); x.AddConsumer(); - x.AddConsumer(); x.AddConsumer(); + x.AddConsumer(); + x.AddConsumer(); + + x.AddSagaStateMachine() + .EntityFrameworkRepository(r => + { + r.ConcurrencyMode = ConcurrencyMode.Optimistic; + r.ExistingDbContext(); + r.UsePostgres(); + }); }); } @@ -72,9 +84,12 @@ public class Program builder.Services.Configure(builder.Configuration.GetSection("UpdateService")); builder.Services.AddTransient(); - + #endregion - + + // Register IClock for saga and service use + builder.Services.AddSingleton(SystemClock.Instance); + builder.Services.AddHealthChecks(); // Authentication & Authorization diff --git a/FictionArchive.Service.NovelService/Sagas/NovelImportSaga.cs b/FictionArchive.Service.NovelService/Sagas/NovelImportSaga.cs new file mode 100644 index 0000000..dedeae4 --- /dev/null +++ b/FictionArchive.Service.NovelService/Sagas/NovelImportSaga.cs @@ -0,0 +1,135 @@ +using FictionArchive.Service.Shared.Contracts.Events; +using MassTransit; +using NodaTime; + +namespace FictionArchive.Service.NovelService.Sagas; + +public class NovelImportSaga : MassTransitStateMachine +{ + public State Importing { get; private set; } = null!; + public State Processing { get; private set; } = null!; + public State Completed { get; private set; } = null!; + public State Failed { get; private set; } = null!; + + public Event NovelImportRequested { get; private set; } = null!; + public Event NovelMetadataImported { get; private set; } = null!; + public Event ChapterPullCompleted { get; private set; } = null!; + public Event FileUploadStatusUpdate { get; private set; } = null!; + public Event> ChapterPullFaulted { get; private set; } = null!; + public Event> FileUploadFaulted { get; private set; } = null!; + + private readonly IClock _clock; + + public NovelImportSaga(IClock clock) + { + _clock = clock; + + InstanceState(x => x.CurrentState); + + Event(() => NovelImportRequested, x => x.CorrelateById(ctx => ctx.Message.ImportId)); + Event(() => NovelMetadataImported, x => x.CorrelateById(ctx => ctx.Message.ImportId)); + Event(() => ChapterPullCompleted, x => x.CorrelateById(ctx => ctx.Message.ImportId)); + Event(() => FileUploadStatusUpdate, x => + { + x.CorrelateById(ctx => ctx.Message.ImportId ?? Guid.Empty); + x.OnMissingInstance(m => m.Discard()); + }); + Event(() => ChapterPullFaulted, x => x.CorrelateById(ctx => ctx.Message.Message.ImportId)); + Event(() => FileUploadFaulted, x => + { + x.CorrelateById(ctx => ctx.Message.Message.ImportId ?? Guid.Empty); + x.OnMissingInstance(m => m.Discard()); + }); + + Initially( + When(NovelImportRequested) + .Then(ctx => + { + ctx.Saga.NovelUrl = ctx.Message.NovelUrl; + ctx.Saga.StartedAt = _clock.GetCurrentInstant(); + }) + .TransitionTo(Importing) + ); + + During(Importing, + When(NovelMetadataImported) + .Then(ctx => + { + ctx.Saga.NovelId = ctx.Message.NovelId; + ctx.Saga.ExpectedChapters = ctx.Message.ChaptersPendingPull; + }) + .IfElse( + ctx => ctx.Saga.ExpectedChapters == 0, + thenBinder => thenBinder + .Then(ctx => ctx.Saga.CompletedAt = _clock.GetCurrentInstant()) + .TransitionTo(Completed) + .PublishAsync(ctx => ctx.Init(new NovelImportCompleted( + ctx.Saga.CorrelationId, + ctx.Saga.NovelId, + true, + null))), + elseBinder => elseBinder.TransitionTo(Processing) + ) + ); + + During(Processing, + When(ChapterPullCompleted) + .Then(ctx => + { + ctx.Saga.CompletedChapters++; + ctx.Saga.ExpectedImages += ctx.Message.ImagesQueued; + }) + .If(ctx => IsComplete(ctx.Saga), ctx => ctx + .Then(c => c.Saga.CompletedAt = _clock.GetCurrentInstant()) + .TransitionTo(Completed) + .PublishAsync(c => c.Init(new NovelImportCompleted( + c.Saga.CorrelationId, + c.Saga.NovelId, + true, + null)))), + + When(FileUploadStatusUpdate) + .Then(ctx => ctx.Saga.CompletedImages++) + .If(ctx => IsComplete(ctx.Saga), ctx => ctx + .Then(c => c.Saga.CompletedAt = _clock.GetCurrentInstant()) + .TransitionTo(Completed) + .PublishAsync(c => c.Init(new NovelImportCompleted( + c.Saga.CorrelationId, + c.Saga.NovelId, + true, + null)))), + + When(ChapterPullFaulted) + .Then(ctx => + { + ctx.Saga.ErrorMessage = ctx.Message.Exceptions.FirstOrDefault()?.Message; + ctx.Saga.CompletedAt = _clock.GetCurrentInstant(); + }) + .TransitionTo(Failed) + .PublishAsync(ctx => ctx.Init(new NovelImportCompleted( + ctx.Saga.CorrelationId, + ctx.Saga.NovelId, + false, + ctx.Saga.ErrorMessage))), + + When(FileUploadFaulted) + .Then(ctx => + { + ctx.Saga.ErrorMessage = ctx.Message.Exceptions.FirstOrDefault()?.Message; + ctx.Saga.CompletedAt = _clock.GetCurrentInstant(); + }) + .TransitionTo(Failed) + .PublishAsync(ctx => ctx.Init(new NovelImportCompleted( + ctx.Saga.CorrelationId, + ctx.Saga.NovelId, + false, + ctx.Saga.ErrorMessage))) + ); + + SetCompletedWhenFinalized(); + } + + private static bool IsComplete(NovelImportSagaState saga) => + saga.CompletedChapters >= saga.ExpectedChapters && + saga.CompletedImages >= saga.ExpectedImages; +} diff --git a/FictionArchive.Service.NovelService/Sagas/NovelImportSagaState.cs b/FictionArchive.Service.NovelService/Sagas/NovelImportSagaState.cs new file mode 100644 index 0000000..7d27381 --- /dev/null +++ b/FictionArchive.Service.NovelService/Sagas/NovelImportSagaState.cs @@ -0,0 +1,29 @@ +using MassTransit; +using NodaTime; + +namespace FictionArchive.Service.NovelService.Sagas; + +public class NovelImportSagaState : SagaStateMachineInstance +{ + public Guid CorrelationId { get; set; } + public string CurrentState { get; set; } = null!; + + // Identity + public string NovelUrl { get; set; } = null!; + public uint? NovelId { get; set; } + + // Chapter tracking + public int ExpectedChapters { get; set; } + public int CompletedChapters { get; set; } + + // Image tracking + public int ExpectedImages { get; set; } + public int CompletedImages { get; set; } + + // Timestamps + public Instant StartedAt { get; set; } + public Instant? CompletedAt { get; set; } + + // Error info + public string? ErrorMessage { get; set; } +} diff --git a/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs b/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs index 49414ec..26d1283 100644 --- a/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs +++ b/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs @@ -1,6 +1,8 @@ +using FictionArchive.Service.NovelService.Models; using FictionArchive.Service.NovelService.Models.Images; using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.NovelService.Models.Novels; +using FictionArchive.Service.NovelService.Sagas; using FictionArchive.Service.Shared.Services.Database; using Microsoft.EntityFrameworkCore; @@ -18,6 +20,8 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger LocalizationKeys { get; set; } public DbSet LocalizationRequests { get; set; } public DbSet Images { get; set; } + public DbSet ActiveImports { get; set; } + public DbSet NovelImportSagaStates { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -36,5 +40,18 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger() .HasIndex("VolumeId", "Order") .IsUnique(); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.ImportId); + entity.HasIndex(e => e.NovelUrl).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.CorrelationId); + entity.HasIndex(e => e.NovelUrl); + entity.HasIndex(e => e.CurrentState); + }); } } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs index 4905a89..ed01566 100644 --- a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs +++ b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs @@ -1,5 +1,6 @@ using FictionArchive.Common.Enums; using FictionArchive.Service.NovelService.Contracts; +using FictionArchive.Service.NovelService.Models; using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.Enums; using FictionArchive.Service.NovelService.Models.Images; @@ -12,6 +13,7 @@ using HtmlAgilityPack; using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using NodaTime; namespace FictionArchive.Service.NovelService.Services; @@ -22,14 +24,16 @@ public class NovelUpdateService private readonly IEnumerable _sourceAdapters; private readonly IPublishEndpoint _publishEndpoint; private readonly NovelUpdateServiceConfiguration _novelUpdateServiceConfiguration; + private readonly IClock _clock; - public NovelUpdateService(NovelServiceDbContext dbContext, ILogger logger, IEnumerable sourceAdapters, IPublishEndpoint publishEndpoint, IOptions novelUpdateServiceConfiguration) + public NovelUpdateService(NovelServiceDbContext dbContext, ILogger logger, IEnumerable sourceAdapters, IPublishEndpoint publishEndpoint, IOptions novelUpdateServiceConfiguration, IClock clock) { _dbContext = dbContext; _logger = logger; _sourceAdapters = sourceAdapters; _publishEndpoint = publishEndpoint; _novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value; + _clock = clock; } #region Helper Methods @@ -299,7 +303,7 @@ public class NovelUpdateService #endregion - public async Task ImportNovel(string novelUrl) + public async Task ImportNovel(Guid importId, string novelUrl) { // Step 1: Get metadata from source adapter NovelMetadata? metadata = null; @@ -417,10 +421,24 @@ public class NovelUpdateService } } + // Count chapters that need pulling + var chaptersNeedingPull = novel.Volumes + .SelectMany(v => v.Chapters) + .Where(c => c.Body?.Texts == null || !c.Body.Texts.Any()) + .ToList(); + + // Publish metadata imported event for saga + await _publishEndpoint.Publish(new NovelMetadataImported( + importId, + novel.Id, + chaptersNeedingPull.Count + )); + // Publish cover image event if needed if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null) { await _publishEndpoint.Publish(new FileUploadRequestCreated( + importId, novel.CoverImage.Id, $"Novels/{novel.Id}/Images/cover.jpg", metadata.CoverImage.Data)); @@ -429,13 +447,14 @@ public class NovelUpdateService // Publish chapter pull events for chapters without body content foreach (var volume in novel.Volumes) { - var chaptersNeedingPull = volume.Chapters + var volumeChaptersNeedingPull = volume.Chapters .Where(c => c.Body?.Texts == null || !c.Body.Texts.Any()) .ToList(); - foreach (var chapter in chaptersNeedingPull) + foreach (var chapter in volumeChaptersNeedingPull) { await _publishEndpoint.Publish(new ChapterPullRequested( + importId, novel.Id, volume.Id, chapter.Order)); @@ -445,7 +464,7 @@ public class NovelUpdateService return novel; } - public async Task PullChapterContents(uint novelId, uint volumeId, uint chapterOrder) + public async Task<(Chapter chapter, int imageCount)> PullChapterContents(Guid importId, uint novelId, uint volumeId, uint chapterOrder) { var novel = await _dbContext.Novels.Where(novel => novel.Id == novelId) .Include(novel => novel.Volumes) @@ -512,12 +531,13 @@ public class NovelUpdateService { var data = rawChapter.ImageData.FirstOrDefault(img => img.Url == image.OriginalPath); await _publishEndpoint.Publish(new FileUploadRequestCreated( + importId, image.Id, $"Novels/{novel.Id}/Images/Chapter-{chapter.Id}/{imgCount++}.jpg", data.Data)); } - return chapter; + return (chapter, chapter.Images.Count); } public async Task UpdateImage(Guid imageId, string newUrl) @@ -548,16 +568,34 @@ public class NovelUpdateService await _dbContext.SaveChangesAsync(); } - public async Task QueueNovelImport(string novelUrl) + public async Task QueueNovelImport(string novelUrl) { - var importNovelRequestEvent = new NovelUpdateRequested(novelUrl); - await _publishEndpoint.Publish(importNovelRequestEvent); + var importId = Guid.NewGuid(); + var activeImport = new ActiveImport + { + ImportId = importId, + NovelUrl = novelUrl, + StartedAt = _clock.GetCurrentInstant() + }; + + try + { + await _dbContext.ActiveImports.AddAsync(activeImport); + await _dbContext.SaveChangesAsync(); + } + catch (DbUpdateException) + { + throw new InvalidOperationException($"An import is already in progress for {novelUrl}"); + } + + var importNovelRequestEvent = new NovelImportRequested(importId, novelUrl); + await _publishEndpoint.Publish(importNovelRequestEvent); return importNovelRequestEvent; } - public async Task QueueChapterPull(uint novelId, uint volumeId, uint chapterOrder) + public async Task QueueChapterPull(Guid importId, uint novelId, uint volumeId, uint chapterOrder) { - var chapterPullEvent = new ChapterPullRequested(novelId, volumeId, chapterOrder); + var chapterPullEvent = new ChapterPullRequested(importId, novelId, volumeId, chapterOrder); await _publishEndpoint.Publish(chapterPullEvent); return chapterPullEvent; } diff --git a/FictionArchive.Service.Shared/Contracts/Events/IChapterPullCompleted.cs b/FictionArchive.Service.Shared/Contracts/Events/IChapterPullCompleted.cs new file mode 100644 index 0000000..6bcda5e --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/IChapterPullCompleted.cs @@ -0,0 +1,10 @@ +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface IChapterPullCompleted +{ + Guid ImportId { get; } + uint ChapterId { get; } + int ImagesQueued { get; } +} + +public record ChapterPullCompleted(Guid ImportId, uint ChapterId, int ImagesQueued) : IChapterPullCompleted; diff --git a/FictionArchive.Service.Shared/Contracts/Events/IChapterPullRequested.cs b/FictionArchive.Service.Shared/Contracts/Events/IChapterPullRequested.cs index a0f3377..3b3f390 100644 --- a/FictionArchive.Service.Shared/Contracts/Events/IChapterPullRequested.cs +++ b/FictionArchive.Service.Shared/Contracts/Events/IChapterPullRequested.cs @@ -2,7 +2,10 @@ namespace FictionArchive.Service.Shared.Contracts.Events; public interface IChapterPullRequested { + Guid ImportId { get; } uint NovelId { get; } uint VolumeId { get; } uint ChapterOrder { get; } } + +public record ChapterPullRequested(Guid ImportId, uint NovelId, uint VolumeId, uint ChapterOrder) : IChapterPullRequested; diff --git a/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestCreated.cs b/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestCreated.cs index 8624ce5..2d25d23 100644 --- a/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestCreated.cs +++ b/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestCreated.cs @@ -2,7 +2,10 @@ namespace FictionArchive.Service.Shared.Contracts.Events; public interface IFileUploadRequestCreated { + Guid? ImportId { get; } Guid RequestId { get; } string FilePath { get; } byte[] FileData { get; } } + +public record FileUploadRequestCreated(Guid? ImportId, Guid RequestId, string FilePath, byte[] FileData) : IFileUploadRequestCreated; diff --git a/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestStatusUpdate.cs b/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestStatusUpdate.cs index bd7380c..7fad414 100644 --- a/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestStatusUpdate.cs +++ b/FictionArchive.Service.Shared/Contracts/Events/IFileUploadRequestStatusUpdate.cs @@ -4,8 +4,11 @@ namespace FictionArchive.Service.Shared.Contracts.Events; public interface IFileUploadRequestStatusUpdate { + Guid? ImportId { get; } Guid RequestId { get; } RequestStatus Status { get; } string? FileAccessUrl { get; } string? ErrorMessage { get; } } + +public record FileUploadRequestStatusUpdate(Guid? ImportId, Guid RequestId, RequestStatus Status, string? FileAccessUrl, string? ErrorMessage) : IFileUploadRequestStatusUpdate; diff --git a/FictionArchive.Service.Shared/Contracts/Events/INovelImportCompleted.cs b/FictionArchive.Service.Shared/Contracts/Events/INovelImportCompleted.cs new file mode 100644 index 0000000..48cf45b --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/INovelImportCompleted.cs @@ -0,0 +1,11 @@ +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface INovelImportCompleted +{ + Guid ImportId { get; } + uint? NovelId { get; } + bool Success { get; } + string? ErrorMessage { get; } +} + +public record NovelImportCompleted(Guid ImportId, uint? NovelId, bool Success, string? ErrorMessage) : INovelImportCompleted; diff --git a/FictionArchive.Service.Shared/Contracts/Events/INovelImportRequested.cs b/FictionArchive.Service.Shared/Contracts/Events/INovelImportRequested.cs new file mode 100644 index 0000000..651d537 --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/INovelImportRequested.cs @@ -0,0 +1,9 @@ +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface INovelImportRequested +{ + Guid ImportId { get; } + string NovelUrl { get; } +} + +public record NovelImportRequested(Guid ImportId, string NovelUrl) : INovelImportRequested; diff --git a/FictionArchive.Service.Shared/Contracts/Events/INovelMetadataImported.cs b/FictionArchive.Service.Shared/Contracts/Events/INovelMetadataImported.cs new file mode 100644 index 0000000..5f05805 --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/INovelMetadataImported.cs @@ -0,0 +1,10 @@ +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface INovelMetadataImported +{ + Guid ImportId { get; } + uint NovelId { get; } + int ChaptersPendingPull { get; } +} + +public record NovelMetadataImported(Guid ImportId, uint NovelId, int ChaptersPendingPull) : INovelMetadataImported; diff --git a/FictionArchive.Service.Shared/Contracts/Events/INovelUpdateRequested.cs b/FictionArchive.Service.Shared/Contracts/Events/INovelUpdateRequested.cs deleted file mode 100644 index d64ac3f..0000000 --- a/FictionArchive.Service.Shared/Contracts/Events/INovelUpdateRequested.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FictionArchive.Service.Shared.Contracts.Events; - -public interface INovelUpdateRequested -{ - string NovelUrl { get; } -} diff --git a/FictionArchive.Service.UserNovelDataService/Consumers/ChapterCreatedConsumer.cs b/FictionArchive.Service.UserNovelDataService/Consumers/ChapterCreatedConsumer.cs index 293bb4a..33952fd 100644 --- a/FictionArchive.Service.UserNovelDataService/Consumers/ChapterCreatedConsumer.cs +++ b/FictionArchive.Service.UserNovelDataService/Consumers/ChapterCreatedConsumer.cs @@ -35,7 +35,7 @@ public class ChapterCreatedConsumer : IConsumer var volumeExists = await _dbContext.Volumes.AnyAsync(v => v.Id == message.VolumeId); if (!volumeExists) { - var volume = new Volume { Id = message.VolumeId }; + var volume = new Volume { Id = message.VolumeId, NovelId = message.NovelId }; _dbContext.Volumes.Add(volume); } @@ -47,7 +47,7 @@ public class ChapterCreatedConsumer : IConsumer return; } - var chapter = new Chapter { Id = message.ChapterId }; + var chapter = new Chapter { Id = message.ChapterId, VolumeId = message.VolumeId }; _dbContext.Chapters.Add(chapter); await _dbContext.SaveChangesAsync(); diff --git a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts index 1f5a4da..476f1c1 100644 --- a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts +++ b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts @@ -82,6 +82,7 @@ export type ChapterDtoFilterInput = { export type ChapterPullRequested = { chapterOrder: Scalars['UnsignedInt']['output']; + importId: Scalars['UUID']['output']; novelId: Scalars['UnsignedInt']['output']; volumeId: Scalars['UnsignedInt']['output']; }; @@ -163,6 +164,7 @@ export type Error = { export type FetchChapterContentsInput = { chapterOrder: Scalars['UnsignedInt']['input']; + importId: Scalars['UUID']['input']; novelId: Scalars['UnsignedInt']['input']; volumeId: Scalars['UnsignedInt']['input']; }; @@ -203,7 +205,7 @@ export type ImportNovelInput = { }; export type ImportNovelPayload = { - novelUpdateRequested: Maybe; + novelImportRequested: Maybe; }; export type InstantFilterInput = { @@ -461,6 +463,11 @@ export type NovelDtoSortInput = { url?: InputMaybe; }; +export type NovelImportRequested = { + importId: Scalars['UUID']['output']; + novelUrl: Scalars['String']['output']; +}; + export const NovelStatus = { Abandoned: 'ABANDONED', Completed: 'COMPLETED', @@ -499,10 +506,6 @@ export type NovelTagDtoFilterInput = { tagType?: InputMaybe; }; -export type NovelUpdateRequested = { - novelUrl: Scalars['String']['output']; -}; - /** A connection to a list of items. */ export type NovelsConnection = { /** A list of edges. */ @@ -1010,7 +1013,7 @@ export type ImportNovelMutationVariables = Exact<{ }>; -export type ImportNovelMutation = { importNovel: { novelUpdateRequested: { novelUrl: string } | null } }; +export type ImportNovelMutation = { importNovel: { novelImportRequested: { importId: any, novelUrl: string } | null } }; export type InviteUserMutationVariables = Exact<{ input: InviteUserInput; @@ -1114,7 +1117,7 @@ export const AddToReadingListDocument = {"kind":"Document","definitions":[{"kind export const CreateReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boolean"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUpdateRequested"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelImportRequested"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importId"}},{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}}]}}]}}]} as unknown as DocumentNode; export const InviteUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InviteUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InviteUserInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userDto"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InvalidOperationError"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const RemoveBookmarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveBookmark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveBookmarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeBookmark"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarkPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const RemoveFromReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveFromReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveFromReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeFromReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql b/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql index 83ea3b2..1d177dd 100644 --- a/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql +++ b/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql @@ -1,6 +1,7 @@ mutation ImportNovel($input: ImportNovelInput!) { importNovel(input: $input) { - novelUpdateRequested { + novelImportRequested { + importId novelUrl } } -- 2.49.1