From ec967770d3b559a556aa9571a50d9c03909a8713 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 28 Jan 2026 12:11:06 -0500 Subject: [PATCH] [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 } }