From be1ebbea395517137d158242019b65ab6eda9b75 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 30 Jan 2026 16:31:42 -0500 Subject: [PATCH 01/14] [FA-misc] Add JobStatus enum --- FictionArchive.Common/Enums/JobStatus.cs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 FictionArchive.Common/Enums/JobStatus.cs diff --git a/FictionArchive.Common/Enums/JobStatus.cs b/FictionArchive.Common/Enums/JobStatus.cs new file mode 100644 index 0000000..3eb99e8 --- /dev/null +++ b/FictionArchive.Common/Enums/JobStatus.cs @@ -0,0 +1,9 @@ +namespace FictionArchive.Common.Enums; + +public enum JobStatus +{ + Failed = -1, + Pending = 0, + InProgress = 1, + Completed = 2 +} -- 2.49.1 From c25f59a4b44be6f3a682794a828ecbfb99483042 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 30 Jan 2026 16:32:01 -0500 Subject: [PATCH 02/14] [FA-misc] Add IJobStatusUpdate event contract and publishing helper --- .../Contracts/Events/IJobStatusUpdate.cs | 23 +++++++++++++++++++ .../Extensions/JobStatusPublisher.cs | 20 ++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 FictionArchive.Service.Shared/Contracts/Events/IJobStatusUpdate.cs create mode 100644 FictionArchive.Service.Shared/Extensions/JobStatusPublisher.cs diff --git a/FictionArchive.Service.Shared/Contracts/Events/IJobStatusUpdate.cs b/FictionArchive.Service.Shared/Contracts/Events/IJobStatusUpdate.cs new file mode 100644 index 0000000..54db073 --- /dev/null +++ b/FictionArchive.Service.Shared/Contracts/Events/IJobStatusUpdate.cs @@ -0,0 +1,23 @@ +using FictionArchive.Common.Enums; + +namespace FictionArchive.Service.Shared.Contracts.Events; + +public interface IJobStatusUpdate +{ + Guid JobId { get; } + Guid? ParentJobId { get; } + string JobType { get; } + string DisplayName { get; } + JobStatus Status { get; } + string? ErrorMessage { get; } + Dictionary? Metadata { get; } +} + +public record JobStatusUpdate( + Guid JobId, + Guid? ParentJobId, + string JobType, + string DisplayName, + JobStatus Status, + string? ErrorMessage, + Dictionary? Metadata) : IJobStatusUpdate; diff --git a/FictionArchive.Service.Shared/Extensions/JobStatusPublisher.cs b/FictionArchive.Service.Shared/Extensions/JobStatusPublisher.cs new file mode 100644 index 0000000..3b44d7e --- /dev/null +++ b/FictionArchive.Service.Shared/Extensions/JobStatusPublisher.cs @@ -0,0 +1,20 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.Shared.Contracts.Events; +using MassTransit; + +namespace FictionArchive.Service.Shared.Extensions; + +public static class JobStatusPublisher +{ + public static Task ReportJobStatus( + this IPublishEndpoint endpoint, + Guid jobId, + string jobType, + string displayName, + JobStatus status, + Guid? parentJobId = null, + string? errorMessage = null, + Dictionary? metadata = null) + => endpoint.Publish(new JobStatusUpdate( + jobId, parentJobId, jobType, displayName, status, errorMessage, metadata)); +} -- 2.49.1 From 9577aa996ae963419f0d7c8f45fde250ca76bfb0 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 30 Jan 2026 16:33:43 -0500 Subject: [PATCH 03/14] [FA-misc] Scaffold ReportingService project Co-Authored-By: Claude Opus 4.5 --- ...ionArchive.Service.ReportingService.csproj | 27 +++++++++++++++++++ .../Program.cs | 12 +++++++++ .../Properties/launchSettings.json | 23 ++++++++++++++++ .../appsettings.json | 27 +++++++++++++++++++ .../subgraph-config.json | 6 +++++ FictionArchive.sln | 8 +++++- 6 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj create mode 100644 FictionArchive.Service.ReportingService/Program.cs create mode 100644 FictionArchive.Service.ReportingService/Properties/launchSettings.json create mode 100644 FictionArchive.Service.ReportingService/appsettings.json create mode 100644 FictionArchive.Service.ReportingService/subgraph-config.json diff --git a/FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj b/FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj new file mode 100644 index 0000000..e486c8c --- /dev/null +++ b/FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + Linux + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + .dockerignore + + + + + + + + diff --git a/FictionArchive.Service.ReportingService/Program.cs b/FictionArchive.Service.ReportingService/Program.cs new file mode 100644 index 0000000..6d98235 --- /dev/null +++ b/FictionArchive.Service.ReportingService/Program.cs @@ -0,0 +1,12 @@ +namespace FictionArchive.Service.ReportingService; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + var app = builder.Build(); + app.MapGet("/healthz", () => "ok"); + app.Run(); + } +} diff --git a/FictionArchive.Service.ReportingService/Properties/launchSettings.json b/FictionArchive.Service.ReportingService/Properties/launchSettings.json new file mode 100644 index 0000000..b6c8106 --- /dev/null +++ b/FictionArchive.Service.ReportingService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5140", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7310;http://localhost:5140", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/FictionArchive.Service.ReportingService/appsettings.json b/FictionArchive.Service.ReportingService/appsettings.json new file mode 100644 index 0000000..ebb5510 --- /dev/null +++ b/FictionArchive.Service.ReportingService/appsettings.json @@ -0,0 +1,27 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=FictionArchive_Reporting;Username=postgres;password=postgres" + }, + "RabbitMQ": { + "ConnectionString": "amqp://localhost", + "ClientIdentifier": "ReportingService" + }, + "OIDC": { + "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh", + "Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh", + "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", + "ValidateIssuer": true, + "ValidateAudience": true, + "ValidateLifetime": true, + "ValidateIssuerSigningKey": true + }, + "AllowedHosts": "*" +} diff --git a/FictionArchive.Service.ReportingService/subgraph-config.json b/FictionArchive.Service.ReportingService/subgraph-config.json new file mode 100644 index 0000000..529f9a0 --- /dev/null +++ b/FictionArchive.Service.ReportingService/subgraph-config.json @@ -0,0 +1,6 @@ +{ + "subgraph": "Reporting", + "http": { + "baseAddress": "http://localhost:5140/graphql" + } +} diff --git a/FictionArchive.sln b/FictionArchive.sln index 8d09302..3479e0f 100644 --- a/FictionArchive.sln +++ b/FictionArchive.sln @@ -1,6 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# +# Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Common", "FictionArchive.Common\FictionArchive.Common.csproj", "{ABF1BA10-9E76-45BE-9947-E20445A68147}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.API", "FictionArchive.API\FictionArchive.API.csproj", "{420CC1A1-9DBC-40EC-B9E3-D4B25D71B9A9}" @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.User EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserNovelDataService", "FictionArchive.Service.UserNovelDataService\FictionArchive.Service.UserNovelDataService.csproj", "{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.ReportingService", "FictionArchive.Service.ReportingService\FictionArchive.Service.ReportingService.csproj", "{F29F7969-2B40-4B92-A8F5-9544A4F700DC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,5 +75,9 @@ Global {A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Debug|Any CPU.Build.0 = Debug|Any CPU {A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Release|Any CPU.ActiveCfg = Release|Any CPU {A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Release|Any CPU.Build.0 = Release|Any CPU + {F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal -- 2.49.1 From 3c835d9cc3ed739ddebdc2f071e0d43e68beb950 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 30 Jan 2026 16:38:46 -0500 Subject: [PATCH 04/14] [FA-misc] Add Job entity and ReportingDbContext --- .../Models/Job.cs | 18 +++++++++++ .../Services/ReportingDbContext.cs | 32 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 FictionArchive.Service.ReportingService/Models/Job.cs create mode 100644 FictionArchive.Service.ReportingService/Services/ReportingDbContext.cs diff --git a/FictionArchive.Service.ReportingService/Models/Job.cs b/FictionArchive.Service.ReportingService/Models/Job.cs new file mode 100644 index 0000000..e79a2b0 --- /dev/null +++ b/FictionArchive.Service.ReportingService/Models/Job.cs @@ -0,0 +1,18 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.Shared.Models; + +namespace FictionArchive.Service.ReportingService.Models; + +public class Job : BaseEntity +{ + public Guid? ParentJobId { get; set; } + public string JobType { get; set; } = null!; + public string DisplayName { get; set; } = null!; + public JobStatus Status { get; set; } + public string? ErrorMessage { get; set; } + public Dictionary? Metadata { get; set; } + + // Navigation + public Job? ParentJob { get; set; } + public List ChildJobs { get; set; } = []; +} diff --git a/FictionArchive.Service.ReportingService/Services/ReportingDbContext.cs b/FictionArchive.Service.ReportingService/Services/ReportingDbContext.cs new file mode 100644 index 0000000..73e5068 --- /dev/null +++ b/FictionArchive.Service.ReportingService/Services/ReportingDbContext.cs @@ -0,0 +1,32 @@ +using FictionArchive.Service.ReportingService.Models; +using FictionArchive.Service.Shared.Services.Database; +using Microsoft.EntityFrameworkCore; + +namespace FictionArchive.Service.ReportingService.Services; + +public class ReportingDbContext : FictionArchiveDbContext +{ + public DbSet Jobs { get; set; } + + public ReportingDbContext(DbContextOptions options, ILogger logger) : base(options, logger) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasIndex(j => j.ParentJobId); + + entity.Property(j => j.Metadata) + .HasColumnType("jsonb"); + + entity.HasOne(j => j.ParentJob) + .WithMany(j => j.ChildJobs) + .HasForeignKey(j => j.ParentJobId) + .OnDelete(DeleteBehavior.SetNull); + }); + } +} -- 2.49.1 From 433f038051eb68c9fd5497a6379ef6ca57e9523d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 30 Jan 2026 16:39:11 -0500 Subject: [PATCH 05/14] [FA-misc] Add JobStatusUpdateConsumer with upsert logic --- .../Consumers/JobStatusUpdateConsumer.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 FictionArchive.Service.ReportingService/Consumers/JobStatusUpdateConsumer.cs diff --git a/FictionArchive.Service.ReportingService/Consumers/JobStatusUpdateConsumer.cs b/FictionArchive.Service.ReportingService/Consumers/JobStatusUpdateConsumer.cs new file mode 100644 index 0000000..3267ef4 --- /dev/null +++ b/FictionArchive.Service.ReportingService/Consumers/JobStatusUpdateConsumer.cs @@ -0,0 +1,66 @@ +using FictionArchive.Service.ReportingService.Models; +using FictionArchive.Service.ReportingService.Services; +using FictionArchive.Service.Shared.Contracts.Events; +using MassTransit; +using Microsoft.EntityFrameworkCore; + +namespace FictionArchive.Service.ReportingService.Consumers; + +public class JobStatusUpdateConsumer : IConsumer +{ + private readonly ILogger _logger; + private readonly ReportingDbContext _dbContext; + + public JobStatusUpdateConsumer( + ILogger logger, + ReportingDbContext dbContext) + { + _logger = logger; + _dbContext = dbContext; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + var existingJob = await _dbContext.Jobs.FirstOrDefaultAsync(j => j.Id == message.JobId); + + if (existingJob == null) + { + var job = new Job + { + Id = message.JobId, + ParentJobId = message.ParentJobId, + JobType = message.JobType, + DisplayName = message.DisplayName, + Status = message.Status, + ErrorMessage = message.ErrorMessage, + Metadata = message.Metadata != null + ? new Dictionary(message.Metadata) + : null + }; + + _dbContext.Jobs.Add(job); + _logger.LogInformation("Created job {JobId} of type {JobType}", message.JobId, message.JobType); + } + else + { + existingJob.Status = message.Status; + existingJob.DisplayName = message.DisplayName; + existingJob.ErrorMessage = message.ErrorMessage; + + if (message.Metadata != null) + { + existingJob.Metadata ??= new Dictionary(); + foreach (var kvp in message.Metadata) + { + existingJob.Metadata[kvp.Key] = kvp.Value; + } + } + + _logger.LogInformation("Updated job {JobId} to status {Status}", message.JobId, message.Status); + } + + await _dbContext.SaveChangesAsync(); + } +} -- 2.49.1 From 2c14ab4936ca8b482f6f08281d38c3fec332409c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 30 Jan 2026 16:40:16 -0500 Subject: [PATCH 06/14] [FA-misc] Add GraphQL job queries with filtering and pagination --- ...ionArchive.Service.ReportingService.csproj | 2 ++ .../GraphQL/JobQueries.cs | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 FictionArchive.Service.ReportingService/GraphQL/JobQueries.cs diff --git a/FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj b/FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj index e486c8c..39d8d02 100644 --- a/FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj +++ b/FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj @@ -8,6 +8,8 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/FictionArchive.Service.ReportingService/GraphQL/JobQueries.cs b/FictionArchive.Service.ReportingService/GraphQL/JobQueries.cs new file mode 100644 index 0000000..2fbe7b8 --- /dev/null +++ b/FictionArchive.Service.ReportingService/GraphQL/JobQueries.cs @@ -0,0 +1,26 @@ +using FictionArchive.Service.ReportingService.Models; +using FictionArchive.Service.ReportingService.Services; +using HotChocolate.Authorization; +using HotChocolate.Data; + +namespace FictionArchive.Service.ReportingService.GraphQL; + +[QueryType] +public static class JobQueries +{ + [UseProjection] + [Authorize] + [UseFirstOrDefault] + public static IQueryable GetJobById( + Guid jobId, + ReportingDbContext db) + => db.Jobs.Where(j => j.Id == jobId); + + [UsePaging] + [UseProjection] + [UseFiltering] + [UseSorting] + [Authorize] + public static IQueryable GetJobs(ReportingDbContext db) + => db.Jobs; +} -- 2.49.1 From 1fda5ad4402170f1513a556b93322357188845d9 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 30 Jan 2026 16:43:26 -0500 Subject: [PATCH 07/14] [FA-misc] Wire up ReportingService Program.cs Co-Authored-By: Claude Opus 4.5 --- .../Program.cs | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/FictionArchive.Service.ReportingService/Program.cs b/FictionArchive.Service.ReportingService/Program.cs index 6d98235..4f43e86 100644 --- a/FictionArchive.Service.ReportingService/Program.cs +++ b/FictionArchive.Service.ReportingService/Program.cs @@ -1,3 +1,10 @@ +using FictionArchive.Common.Extensions; +using FictionArchive.Service.ReportingService.Consumers; +using FictionArchive.Service.ReportingService.Services; +using FictionArchive.Service.ReportingService.GraphQL; +using FictionArchive.Service.Shared; +using FictionArchive.Service.Shared.Extensions; + namespace FictionArchive.Service.ReportingService; public class Program @@ -5,8 +12,68 @@ public class Program public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); + + var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args); + + builder.AddLocalAppsettings(); + + builder.Services.AddHealthChecks(); + + #region MassTransit + + if (!isSchemaExport) + { + builder.Services.AddFictionArchiveMassTransit( + builder.Configuration, + x => + { + x.AddConsumer(); + }); + } + + #endregion + + #region GraphQL + + builder.Services.AddGraphQLServer() + .AddQueryConventions() + .AddTypeExtension(typeof(JobQueries)) + .ApplySaneDefaults() + .AddAuthorization(); + + #endregion + + #region Database + + builder.Services.RegisterDbContext( + builder.Configuration.GetConnectionString("DefaultConnection"), + skipInfrastructure: isSchemaExport); + + #endregion + + // Authentication & Authorization + builder.Services.AddOidcAuthentication(builder.Configuration); + builder.Services.AddFictionArchiveAuthorization(); + var app = builder.Build(); - app.MapGet("/healthz", () => "ok"); - app.Run(); + + // Update database (skip in schema export mode) + if (!isSchemaExport) + { + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.UpdateDatabase(); + } + + app.UseHttpsRedirection(); + + app.MapHealthChecks("/healthz"); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapGraphQL(); + + app.RunWithGraphQLCommands(args); } } -- 2.49.1 From 1057e1bcd422d9952d3c8bebd9aed0f68a5e359e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 30 Jan 2026 16:43:48 -0500 Subject: [PATCH 08/14] [FA-misc] Add initial ReportingService migration Co-Authored-By: Claude Opus 4.5 --- .../20260130214338_InitialCreate.Designer.cs | 86 +++++++++++++++++++ .../20260130214338_InitialCreate.cs | 54 ++++++++++++ .../ReportingDbContextModelSnapshot.cs | 83 ++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 FictionArchive.Service.ReportingService/Migrations/20260130214338_InitialCreate.Designer.cs create mode 100644 FictionArchive.Service.ReportingService/Migrations/20260130214338_InitialCreate.cs create mode 100644 FictionArchive.Service.ReportingService/Migrations/ReportingDbContextModelSnapshot.cs diff --git a/FictionArchive.Service.ReportingService/Migrations/20260130214338_InitialCreate.Designer.cs b/FictionArchive.Service.ReportingService/Migrations/20260130214338_InitialCreate.Designer.cs new file mode 100644 index 0000000..3e548e0 --- /dev/null +++ b/FictionArchive.Service.ReportingService/Migrations/20260130214338_InitialCreate.Designer.cs @@ -0,0 +1,86 @@ +// +using System; +using System.Collections.Generic; +using FictionArchive.Service.ReportingService.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.ReportingService.Migrations +{ + [DbContext(typeof(ReportingDbContext))] + [Migration("20260130214338_InitialCreate")] + partial class InitialCreate + { + /// + 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.ReportingService.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property>("Metadata") + .HasColumnType("jsonb"); + + b.Property("ParentJobId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ParentJobId"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("FictionArchive.Service.ReportingService.Models.Job", b => + { + b.HasOne("FictionArchive.Service.ReportingService.Models.Job", "ParentJob") + .WithMany("ChildJobs") + .HasForeignKey("ParentJobId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ParentJob"); + }); + + modelBuilder.Entity("FictionArchive.Service.ReportingService.Models.Job", b => + { + b.Navigation("ChildJobs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FictionArchive.Service.ReportingService/Migrations/20260130214338_InitialCreate.cs b/FictionArchive.Service.ReportingService/Migrations/20260130214338_InitialCreate.cs new file mode 100644 index 0000000..262bde0 --- /dev/null +++ b/FictionArchive.Service.ReportingService/Migrations/20260130214338_InitialCreate.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace FictionArchive.Service.ReportingService.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Jobs", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ParentJobId = table.Column(type: "uuid", nullable: true), + JobType = table.Column(type: "text", nullable: false), + DisplayName = table.Column(type: "text", nullable: false), + Status = table.Column(type: "integer", nullable: false), + ErrorMessage = table.Column(type: "text", nullable: true), + Metadata = table.Column>(type: "jsonb", nullable: true), + CreatedTime = table.Column(type: "timestamp with time zone", nullable: false), + LastUpdatedTime = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Jobs", x => x.Id); + table.ForeignKey( + name: "FK_Jobs_Jobs_ParentJobId", + column: x => x.ParentJobId, + principalTable: "Jobs", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_ParentJobId", + table: "Jobs", + column: "ParentJobId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Jobs"); + } + } +} diff --git a/FictionArchive.Service.ReportingService/Migrations/ReportingDbContextModelSnapshot.cs b/FictionArchive.Service.ReportingService/Migrations/ReportingDbContextModelSnapshot.cs new file mode 100644 index 0000000..7b6a936 --- /dev/null +++ b/FictionArchive.Service.ReportingService/Migrations/ReportingDbContextModelSnapshot.cs @@ -0,0 +1,83 @@ +// +using System; +using System.Collections.Generic; +using FictionArchive.Service.ReportingService.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FictionArchive.Service.ReportingService.Migrations +{ + [DbContext(typeof(ReportingDbContext))] + partial class ReportingDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FictionArchive.Service.ReportingService.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property>("Metadata") + .HasColumnType("jsonb"); + + b.Property("ParentJobId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ParentJobId"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("FictionArchive.Service.ReportingService.Models.Job", b => + { + b.HasOne("FictionArchive.Service.ReportingService.Models.Job", "ParentJob") + .WithMany("ChildJobs") + .HasForeignKey("ParentJobId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ParentJob"); + }); + + modelBuilder.Entity("FictionArchive.Service.ReportingService.Models.Job", b => + { + b.Navigation("ChildJobs"); + }); +#pragma warning restore 612, 618 + } + } +} -- 2.49.1 From 2e4e2c26aa7dece382d201d8181f1140eb441c06 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 30 Jan 2026 16:45:16 -0500 Subject: [PATCH 09/14] [FA-misc] Add ReportingService Dockerfile and docker-compose entry Co-Authored-By: Claude Opus 4.5 --- .../Dockerfile | 23 +++++++++++++++++++ docker-compose.yml | 15 ++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 FictionArchive.Service.ReportingService/Dockerfile diff --git a/FictionArchive.Service.ReportingService/Dockerfile b/FictionArchive.Service.ReportingService/Dockerfile new file mode 100644 index 0000000..a8a453b --- /dev/null +++ b/FictionArchive.Service.ReportingService/Dockerfile @@ -0,0 +1,23 @@ +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.ReportingService/FictionArchive.Service.ReportingService.csproj", "FictionArchive.Service.ReportingService/"] +RUN dotnet restore "FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj" +COPY . . +WORKDIR "/src/FictionArchive.Service.ReportingService" +RUN dotnet build "./FictionArchive.Service.ReportingService.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./FictionArchive.Service.ReportingService.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.ReportingService.dll"] diff --git a/docker-compose.yml b/docker-compose.yml index 2078e52..161541f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -157,6 +157,20 @@ services: condition: service_healthy restart: unless-stopped + reporting-service: + image: git.orfl.xyz/conco/fictionarchive-reporting-service:latest + networks: + - fictionarchive + environment: + ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_Reporting;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres} + RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + restart: unless-stopped + # =========================================== # API Gateway # =========================================== @@ -179,6 +193,7 @@ services: - file-service - user-service - usernoveldata-service + - reporting-service restart: unless-stopped # =========================================== -- 2.49.1 From 7c3df7ab11f27cbc900a478ea130a30039e4c54d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 30 Jan 2026 16:47:26 -0500 Subject: [PATCH 10/14] [FA-misc] Add ReportingService consumer unit tests --- .../Consumers/JobStatusUpdateConsumerTests.cs | 233 ++++++++++++++++++ ...hive.Service.ReportingService.Tests.csproj | 32 +++ FictionArchive.sln | 6 + 3 files changed, 271 insertions(+) create mode 100644 FictionArchive.Service.ReportingService.Tests/Consumers/JobStatusUpdateConsumerTests.cs create mode 100644 FictionArchive.Service.ReportingService.Tests/FictionArchive.Service.ReportingService.Tests.csproj diff --git a/FictionArchive.Service.ReportingService.Tests/Consumers/JobStatusUpdateConsumerTests.cs b/FictionArchive.Service.ReportingService.Tests/Consumers/JobStatusUpdateConsumerTests.cs new file mode 100644 index 0000000..98fdb7f --- /dev/null +++ b/FictionArchive.Service.ReportingService.Tests/Consumers/JobStatusUpdateConsumerTests.cs @@ -0,0 +1,233 @@ +using System.Text.Json; +using FictionArchive.Common.Enums; +using FictionArchive.Service.ReportingService.Consumers; +using FictionArchive.Service.ReportingService.Models; +using FictionArchive.Service.ReportingService.Services; +using FictionArchive.Service.Shared.Contracts.Events; +using FluentAssertions; +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace FictionArchive.Service.ReportingService.Tests.Consumers; + +public class JobStatusUpdateConsumerTests : IDisposable +{ + private readonly ReportingDbContext _dbContext; + private readonly JobStatusUpdateConsumer _consumer; + + public JobStatusUpdateConsumerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _dbContext = new TestReportingDbContext(options, NullLogger.Instance); + _consumer = new JobStatusUpdateConsumer( + NullLogger.Instance, + _dbContext); + } + + [Fact] + public async Task Should_create_new_job_on_first_event() + { + var jobId = Guid.NewGuid(); + var context = CreateConsumeContext(new JobStatusUpdate( + jobId, null, "TestJob", "Test job display", + JobStatus.InProgress, null, new() { ["key1"] = "value1" })); + + await _consumer.Consume(context); + + var job = await _dbContext.Jobs.FindAsync(jobId); + job.Should().NotBeNull(); + job!.JobType.Should().Be("TestJob"); + job.DisplayName.Should().Be("Test job display"); + job.Status.Should().Be(JobStatus.InProgress); + job.Metadata.Should().ContainKey("key1").WhoseValue.Should().Be("value1"); + } + + [Fact] + public async Task Should_update_status_on_subsequent_event() + { + var jobId = Guid.NewGuid(); + + // First event: create + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, null, "TestJob", "Test job", + JobStatus.InProgress, null, null))); + + // Second event: update + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, null, "TestJob", "Test job", + JobStatus.Completed, null, null))); + + var job = await _dbContext.Jobs.FindAsync(jobId); + job!.Status.Should().Be(JobStatus.Completed); + } + + [Fact] + public async Task Should_merge_metadata_on_update() + { + var jobId = Guid.NewGuid(); + + // First event with initial metadata + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, null, "TestJob", "Test job", + JobStatus.InProgress, null, new() { ["NovelId"] = "42" }))); + + // Second event with additional metadata + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, null, "TestJob", "Test job", + JobStatus.Completed, null, new() { ["ChapterId"] = "7" }))); + + var job = await _dbContext.Jobs.FindAsync(jobId); + job!.Metadata.Should().ContainKey("NovelId").WhoseValue.Should().Be("42"); + job.Metadata.Should().ContainKey("ChapterId").WhoseValue.Should().Be("7"); + } + + [Fact] + public async Task Should_not_overwrite_job_type_on_update() + { + var jobId = Guid.NewGuid(); + + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, null, "OriginalType", "Test job", + JobStatus.InProgress, null, null))); + + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, null, "DifferentType", "Test job", + JobStatus.Completed, null, null))); + + var job = await _dbContext.Jobs.FindAsync(jobId); + job!.JobType.Should().Be("OriginalType"); + } + + [Fact] + public async Task Should_not_overwrite_parent_job_id_on_update() + { + var jobId = Guid.NewGuid(); + var parentId = Guid.NewGuid(); + + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, parentId, "TestJob", "Test job", + JobStatus.InProgress, null, null))); + + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, null, "TestJob", "Test job", + JobStatus.Completed, null, null))); + + var job = await _dbContext.Jobs.FindAsync(jobId); + job!.ParentJobId.Should().Be(parentId); + } + + [Fact] + public async Task Should_set_error_message_on_failure() + { + var jobId = Guid.NewGuid(); + + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, null, "TestJob", "Test job", + JobStatus.Failed, "Something went wrong", null))); + + var job = await _dbContext.Jobs.FindAsync(jobId); + job!.Status.Should().Be(JobStatus.Failed); + job.ErrorMessage.Should().Be("Something went wrong"); + } + + [Fact] + public async Task Should_store_parent_job_id() + { + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + parentId, null, "ParentJob", "Parent", + JobStatus.InProgress, null, null))); + + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + childId, parentId, "ChildJob", "Child", + JobStatus.InProgress, null, null))); + + var child = await _dbContext.Jobs.FindAsync(childId); + child!.ParentJobId.Should().Be(parentId); + } + + [Fact] + public async Task Should_handle_null_metadata_on_create() + { + var jobId = Guid.NewGuid(); + + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, null, "TestJob", "Test job", + JobStatus.InProgress, null, null))); + + var job = await _dbContext.Jobs.FindAsync(jobId); + job!.Metadata.Should().BeNull(); + } + + [Fact] + public async Task Should_add_metadata_to_job_with_null_metadata() + { + var jobId = Guid.NewGuid(); + + // First event: no metadata + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, null, "TestJob", "Test job", + JobStatus.InProgress, null, null))); + + // Second event: adds metadata + await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( + jobId, null, "TestJob", "Test job", + JobStatus.Completed, null, new() { ["result"] = "success" }))); + + var job = await _dbContext.Jobs.FindAsync(jobId); + job!.Metadata.Should().ContainKey("result").WhoseValue.Should().Be("success"); + } + + private static ConsumeContext CreateConsumeContext(JobStatusUpdate message) + { + var context = Substitute.For>(); + context.Message.Returns(message); + return context; + } + + public void Dispose() + { + _dbContext.Dispose(); + } + + /// + /// Test-specific subclass that adds a JSON value converter for Dictionary properties, + /// since the InMemory provider does not support the jsonb column type used in production. + /// + private class TestReportingDbContext : ReportingDbContext + { + public TestReportingDbContext(DbContextOptions options, ILogger logger) + : base(options, logger) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.Property(j => j.Metadata) + .HasConversion( + v => v == null ? null : JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => v == null ? null : JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null)) + .HasColumnType(null!); + }); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + // Skip base OnConfiguring to avoid adding AuditInterceptor + // which is not needed for unit tests + } + } +} diff --git a/FictionArchive.Service.ReportingService.Tests/FictionArchive.Service.ReportingService.Tests.csproj b/FictionArchive.Service.ReportingService.Tests/FictionArchive.Service.ReportingService.Tests.csproj new file mode 100644 index 0000000..81228be --- /dev/null +++ b/FictionArchive.Service.ReportingService.Tests/FictionArchive.Service.ReportingService.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/FictionArchive.sln b/FictionArchive.sln index 3479e0f..45d052b 100644 --- a/FictionArchive.sln +++ b/FictionArchive.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.User EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.ReportingService", "FictionArchive.Service.ReportingService\FictionArchive.Service.ReportingService.csproj", "{F29F7969-2B40-4B92-A8F5-9544A4F700DC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.ReportingService.Tests", "FictionArchive.Service.ReportingService.Tests\FictionArchive.Service.ReportingService.Tests.csproj", "{E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -79,5 +81,9 @@ Global {F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Debug|Any CPU.Build.0 = Debug|Any CPU {F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Release|Any CPU.ActiveCfg = Release|Any CPU {F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Release|Any CPU.Build.0 = Release|Any CPU + {E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal -- 2.49.1 From bdb863a03288117570ea72324d633dae4f59ea03 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 31 Jan 2026 10:48:14 -0500 Subject: [PATCH 11/14] [FA-misc] CICD Updates --- .gitea/workflows/build-gateway.yml | 9 +++++++++ .gitea/workflows/release.yml | 2 ++ 2 files changed, 11 insertions(+) diff --git a/.gitea/workflows/build-gateway.yml b/.gitea/workflows/build-gateway.yml index 4853865..003e31b 100644 --- a/.gitea/workflows/build-gateway.yml +++ b/.gitea/workflows/build-gateway.yml @@ -31,6 +31,9 @@ jobs: - name: usernoveldata-service project: FictionArchive.Service.UserNovelDataService subgraph: UserNovelData + - name: reporting-service + project: FictionArchive.Service.ReportingService + subgraph: Reporting steps: - name: Checkout uses: actions/checkout@v4 @@ -119,6 +122,12 @@ jobs: name: usernoveldata-service-subgraph path: subgraphs/usernoveldata + - name: Download Reporting Service subgraph + uses: christopherhx/gitea-download-artifact@v4 + with: + name: reporting-service-subgraph + path: subgraphs/reporting + - name: Configure subgraph URLs for Docker run: | for fsp in subgraphs/*/*.fsp; do diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 3ee7cd4..6fef035 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -27,6 +27,8 @@ jobs: dockerfile: FictionArchive.Service.SchedulerService/Dockerfile - name: usernoveldata-service dockerfile: FictionArchive.Service.UserNovelDataService/Dockerfile + - name: reporting-service + dockerfile: FictionArchive.Service.ReportingService/Dockerfile steps: - name: Checkout uses: actions/checkout@v4 -- 2.49.1 From 9bc39c3abf11997df5c9f2beb8d5edf7c3cd406a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 1 Feb 2026 10:19:52 -0500 Subject: [PATCH 12/14] [FA-misc] Reporting service seems to be working --- .../FileUploadRequestCreatedConsumer.cs | 15 +++++ .../Sagas/NovelImportSagaTests.cs | 57 +++++++++++++++++++ .../Consumers/ChapterPullRequestedConsumer.cs | 12 ++++ .../Sagas/NovelImportSaga.cs | 33 +++++++++-- .../Extensions/DatabaseExtensions.cs | 8 ++- .../FictionArchive.Service.Shared.csproj | 1 + 6 files changed, 121 insertions(+), 5 deletions(-) diff --git a/FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs b/FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs index 590339e..b5d38d8 100644 --- a/FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs +++ b/FictionArchive.Service.FileService/Consumers/FileUploadRequestCreatedConsumer.cs @@ -3,6 +3,7 @@ using Amazon.S3.Model; using FictionArchive.Common.Enums; using FictionArchive.Service.FileService.Models; using FictionArchive.Service.Shared.Contracts.Events; +using FictionArchive.Service.Shared.Extensions; using MassTransit; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -35,6 +36,10 @@ public class FileUploadRequestCreatedConsumer : IConsumer { ["FileAccessUrl"] = fileAccessUrl }); } } diff --git a/FictionArchive.Service.NovelService.Tests/Sagas/NovelImportSagaTests.cs b/FictionArchive.Service.NovelService.Tests/Sagas/NovelImportSagaTests.cs index b6027aa..6801363 100644 --- a/FictionArchive.Service.NovelService.Tests/Sagas/NovelImportSagaTests.cs +++ b/FictionArchive.Service.NovelService.Tests/Sagas/NovelImportSagaTests.cs @@ -27,6 +27,11 @@ public class NovelImportSagaTests var sagaHarness = harness.GetSagaStateMachineHarness(); (await sagaHarness.Exists(importId, x => x.Importing)).HasValue.Should().BeTrue(); + + (await harness.Published.Any(x => + x.Context.Message.JobId == importId && + x.Context.Message.Status == JobStatus.InProgress && + x.Context.Message.JobType == "NovelImport")).Should().BeTrue(); } [Fact] @@ -45,6 +50,11 @@ public class NovelImportSagaTests (await harness.Published.Any(x => x.Context.Message.ImportId == importId && x.Context.Message.Success)).Should().BeTrue(); + + (await harness.Published.Any(x => + x.Context.Message.JobId == importId && + x.Context.Message.Status == JobStatus.Completed && + x.Context.Message.JobType == "NovelImport")).Should().BeTrue(); } [Fact] @@ -79,6 +89,11 @@ public class NovelImportSagaTests var sagaHarness = harness.GetSagaStateMachineHarness(); (await sagaHarness.Exists(importId, x => x.Completed)).HasValue.Should().BeTrue(); + + (await harness.Published.Any(x => + x.Context.Message.JobId == importId && + x.Context.Message.Status == JobStatus.Completed && + x.Context.Message.JobType == "NovelImport")).Should().BeTrue(); } [Fact] @@ -121,6 +136,48 @@ public class NovelImportSagaTests (await harness.Published.Any(x => x.Context.Message.ImportId == importId && x.Context.Message.Success)).Should().BeTrue(); + + (await harness.Published.Any(x => + x.Context.Message.JobId == importId && + x.Context.Message.Status == JobStatus.Completed && + x.Context.Message.JobType == "NovelImport")).Should().BeTrue(); + } + + [Fact] + public async Task Should_publish_failed_job_status_on_chapter_pull_fault() + { + 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, 1, false)); + + var sagaHarness = harness.GetSagaStateMachineHarness(); + (await sagaHarness.Exists(importId, x => x.Processing)).HasValue.Should().BeTrue(); + + await harness.Bus.Publish>(new + { + Message = new ChapterPullRequested(importId, 1, 1, 1), + Exceptions = new[] + { + new + { + ExceptionType = typeof(Exception).FullName!, + Message = "Chapter pull failed", + StackTrace = "stack trace", + InnerException = (object?)null + } + } + }); + + (await sagaHarness.Exists(importId, x => x.Failed)).HasValue.Should().BeTrue(); + + (await harness.Published.Any(x => + x.Context.Message.JobId == importId && + x.Context.Message.Status == JobStatus.Failed && + x.Context.Message.JobType == "NovelImport")).Should().BeTrue(); } private ServiceProvider CreateTestProvider() diff --git a/FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs b/FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs index 7bed18e..5db643e 100644 --- a/FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs +++ b/FictionArchive.Service.NovelService/Consumers/ChapterPullRequestedConsumer.cs @@ -1,5 +1,7 @@ +using FictionArchive.Common.Enums; using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.Shared.Contracts.Events; +using FictionArchive.Service.Shared.Extensions; using MassTransit; using Microsoft.Extensions.Logging; @@ -21,6 +23,11 @@ public class ChapterPullRequestedConsumer : IConsumer public async Task Consume(ConsumeContext context) { var message = context.Message; + var chapterJobId = Guid.NewGuid(); + + await context.ReportJobStatus( + chapterJobId, "ChapterPull", $"Pull chapter {message.ChapterOrder}", + JobStatus.InProgress, parentJobId: message.ImportId); var (chapter, imageCount) = await _novelUpdateService.PullChapterContents( message.ImportId, @@ -33,5 +40,10 @@ public class ChapterPullRequestedConsumer : IConsumer chapter.Id, imageCount )); + + await context.ReportJobStatus( + chapterJobId, "ChapterPull", $"Pull chapter {message.ChapterOrder}", + JobStatus.Completed, parentJobId: message.ImportId, + metadata: new Dictionary { ["ChapterId"] = chapter.Id.ToString() }); } } diff --git a/FictionArchive.Service.NovelService/Sagas/NovelImportSaga.cs b/FictionArchive.Service.NovelService/Sagas/NovelImportSaga.cs index 7ab4358..84e07a3 100644 --- a/FictionArchive.Service.NovelService/Sagas/NovelImportSaga.cs +++ b/FictionArchive.Service.NovelService/Sagas/NovelImportSaga.cs @@ -1,3 +1,4 @@ +using FictionArchive.Common.Enums; using FictionArchive.Service.Shared.Contracts.Events; using MassTransit; using NodaTime; @@ -49,6 +50,10 @@ public class NovelImportSaga : MassTransitStateMachine ctx.Saga.StartedAt = _clock.GetCurrentInstant(); }) .TransitionTo(Importing) + .PublishAsync(ctx => ctx.Init(new JobStatusUpdate( + ctx.Saga.CorrelationId, null, "NovelImport", + $"Import {ctx.Saga.NovelUrl}", JobStatus.InProgress, + null, new Dictionary { ["NovelUrl"] = ctx.Saga.NovelUrl }))) ); During(Importing, @@ -68,7 +73,11 @@ public class NovelImportSaga : MassTransitStateMachine ctx.Saga.CorrelationId, ctx.Saga.NovelId, true, - null))), + null))) + .PublishAsync(ctx => ctx.Init(new JobStatusUpdate( + ctx.Saga.CorrelationId, null, "NovelImport", + $"Import {ctx.Saga.NovelUrl}", JobStatus.Completed, + null, new Dictionary { ["NovelId"] = ctx.Saga.NovelId.ToString() }))), elseBinder => elseBinder.TransitionTo(Processing) ) ); @@ -87,7 +96,11 @@ public class NovelImportSaga : MassTransitStateMachine c.Saga.CorrelationId, c.Saga.NovelId, true, - null)))), + null))) + .PublishAsync(c => c.Init(new JobStatusUpdate( + c.Saga.CorrelationId, null, "NovelImport", + $"Import {c.Saga.NovelUrl}", JobStatus.Completed, + null, new Dictionary { ["NovelId"] = c.Saga.NovelId.ToString() })))), When(FileUploadStatusUpdate) .Then(ctx => ctx.Saga.CompletedImages++) @@ -98,7 +111,11 @@ public class NovelImportSaga : MassTransitStateMachine c.Saga.CorrelationId, c.Saga.NovelId, true, - null)))), + null))) + .PublishAsync(c => c.Init(new JobStatusUpdate( + c.Saga.CorrelationId, null, "NovelImport", + $"Import {c.Saga.NovelUrl}", JobStatus.Completed, + null, new Dictionary { ["NovelId"] = c.Saga.NovelId.ToString() })))), When(ChapterPullFaulted) .Then(ctx => @@ -111,7 +128,11 @@ public class NovelImportSaga : MassTransitStateMachine ctx.Saga.CorrelationId, ctx.Saga.NovelId, false, - ctx.Saga.ErrorMessage))), + ctx.Saga.ErrorMessage))) + .PublishAsync(ctx => ctx.Init(new JobStatusUpdate( + ctx.Saga.CorrelationId, null, "NovelImport", + $"Import {ctx.Saga.NovelUrl}", JobStatus.Failed, + ctx.Saga.ErrorMessage, null))), When(FileUploadFaulted) .Then(ctx => @@ -125,6 +146,10 @@ public class NovelImportSaga : MassTransitStateMachine ctx.Saga.NovelId, false, ctx.Saga.ErrorMessage))) + .PublishAsync(ctx => ctx.Init(new JobStatusUpdate( + ctx.Saga.CorrelationId, null, "NovelImport", + $"Import {ctx.Saga.NovelUrl}", JobStatus.Failed, + ctx.Saga.ErrorMessage, null))) ); SetCompletedWhenFinalized(); diff --git a/FictionArchive.Service.Shared/Extensions/DatabaseExtensions.cs b/FictionArchive.Service.Shared/Extensions/DatabaseExtensions.cs index 03b2a01..c36a816 100644 --- a/FictionArchive.Service.Shared/Extensions/DatabaseExtensions.cs +++ b/FictionArchive.Service.Shared/Extensions/DatabaseExtensions.cs @@ -1,6 +1,7 @@ using FictionArchive.Service.Shared.Services.Database; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Npgsql; namespace FictionArchive.Service.Shared.Extensions; @@ -21,9 +22,14 @@ public static class DatabaseExtensions } else { + var dataSourceBuilder = new Npgsql.NpgsqlDataSourceBuilder(connectionString); + dataSourceBuilder.UseNodaTime(); + dataSourceBuilder.UseJsonNet(); + var dataSource = dataSourceBuilder.Build(); + services.AddDbContext(options => { - options.UseNpgsql(connectionString, o => + options.UseNpgsql(dataSource, o => { o.UseNodaTime(); }); diff --git a/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj b/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj index 9cbe796..d4023c1 100644 --- a/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj +++ b/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj @@ -30,6 +30,7 @@ + -- 2.49.1 From 7ccc3ade9eb1dd17b668f055c9b3f50eaf88556d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 1 Feb 2026 12:07:57 -0500 Subject: [PATCH 13/14] [FA-misc] Reporting service now has a status page on the frontend --- .../GraphQL/JobQueries.cs | 26 --- .../GraphQL/Query.cs | 71 ++++++ .../Models/DTOs/JobDto.cs | 19 ++ .../Program.cs | 3 +- .../Properties/launchSettings.json | 1 + .../subgraph-config.json | 2 +- .../GraphQL/Query.cs | 2 +- .../src/lib/components/JobFilters.svelte | 217 ++++++++++++++++++ .../src/lib/components/JobRow.svelte | 168 ++++++++++++++ .../src/lib/components/JobStatusBadge.svelte | 20 ++ .../src/lib/components/JobsTab.svelte | 179 +++++++++++++++ .../src/lib/components/JobsTable.svelte | 42 ++++ .../src/lib/components/Navbar.svelte | 4 + .../src/lib/components/StatusPage.svelte | 21 ++ .../src/lib/graphql/__generated__/graphql.ts | 123 +++++++++- .../src/lib/graphql/queries/jobs.graphql | 40 ++++ .../src/lib/utils/jobFilterParams.ts | 131 +++++++++++ .../src/pages/status/index.astro | 8 + 18 files changed, 1046 insertions(+), 31 deletions(-) delete mode 100644 FictionArchive.Service.ReportingService/GraphQL/JobQueries.cs create mode 100644 FictionArchive.Service.ReportingService/GraphQL/Query.cs create mode 100644 FictionArchive.Service.ReportingService/Models/DTOs/JobDto.cs create mode 100644 fictionarchive-web-astro/src/lib/components/JobFilters.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/JobRow.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/JobStatusBadge.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/JobsTab.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/JobsTable.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/StatusPage.svelte create mode 100644 fictionarchive-web-astro/src/lib/graphql/queries/jobs.graphql create mode 100644 fictionarchive-web-astro/src/lib/utils/jobFilterParams.ts create mode 100644 fictionarchive-web-astro/src/pages/status/index.astro diff --git a/FictionArchive.Service.ReportingService/GraphQL/JobQueries.cs b/FictionArchive.Service.ReportingService/GraphQL/JobQueries.cs deleted file mode 100644 index 2fbe7b8..0000000 --- a/FictionArchive.Service.ReportingService/GraphQL/JobQueries.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FictionArchive.Service.ReportingService.Models; -using FictionArchive.Service.ReportingService.Services; -using HotChocolate.Authorization; -using HotChocolate.Data; - -namespace FictionArchive.Service.ReportingService.GraphQL; - -[QueryType] -public static class JobQueries -{ - [UseProjection] - [Authorize] - [UseFirstOrDefault] - public static IQueryable GetJobById( - Guid jobId, - ReportingDbContext db) - => db.Jobs.Where(j => j.Id == jobId); - - [UsePaging] - [UseProjection] - [UseFiltering] - [UseSorting] - [Authorize] - public static IQueryable GetJobs(ReportingDbContext db) - => db.Jobs; -} diff --git a/FictionArchive.Service.ReportingService/GraphQL/Query.cs b/FictionArchive.Service.ReportingService/GraphQL/Query.cs new file mode 100644 index 0000000..6edc2ee --- /dev/null +++ b/FictionArchive.Service.ReportingService/GraphQL/Query.cs @@ -0,0 +1,71 @@ +using FictionArchive.Service.ReportingService.Models.DTOs; +using FictionArchive.Service.ReportingService.Services; +using HotChocolate.Authorization; +using HotChocolate.Data; + +namespace FictionArchive.Service.ReportingService.GraphQL; + +public class Query +{ + [Authorize] + //[UseProjection] + [UseFirstOrDefault] + public IQueryable GetJobById( + Guid jobId, + ReportingDbContext db) + => db.Jobs.Where(j => j.Id == jobId).Select(j => new JobDto + { + Id = j.Id, + CreatedTime = j.CreatedTime, + LastUpdatedTime = j.LastUpdatedTime, + ParentJobId = j.ParentJobId, + JobType = j.JobType, + DisplayName = j.DisplayName, + Status = j.Status, + ErrorMessage = j.ErrorMessage, + Metadata = j.Metadata, + ChildJobs = j.ChildJobs.Select(c => new JobDto + { + Id = c.Id, + CreatedTime = c.CreatedTime, + LastUpdatedTime = c.LastUpdatedTime, + ParentJobId = c.ParentJobId, + JobType = c.JobType, + DisplayName = c.DisplayName, + Status = c.Status, + ErrorMessage = c.ErrorMessage, + Metadata = c.Metadata + }) + }); + + [Authorize] + [UsePaging] + //[UseProjection] + [UseFiltering] + [UseSorting] + public IQueryable GetJobs(ReportingDbContext db) + => db.Jobs.Select(j => new JobDto + { + Id = j.Id, + CreatedTime = j.CreatedTime, + LastUpdatedTime = j.LastUpdatedTime, + ParentJobId = j.ParentJobId, + JobType = j.JobType, + DisplayName = j.DisplayName, + Status = j.Status, + ErrorMessage = j.ErrorMessage, + Metadata = j.Metadata, + ChildJobs = j.ChildJobs.Select(c => new JobDto + { + Id = c.Id, + CreatedTime = c.CreatedTime, + LastUpdatedTime = c.LastUpdatedTime, + ParentJobId = c.ParentJobId, + JobType = c.JobType, + DisplayName = c.DisplayName, + Status = c.Status, + ErrorMessage = c.ErrorMessage, + Metadata = c.Metadata + }) + }); +} diff --git a/FictionArchive.Service.ReportingService/Models/DTOs/JobDto.cs b/FictionArchive.Service.ReportingService/Models/DTOs/JobDto.cs new file mode 100644 index 0000000..dce71a0 --- /dev/null +++ b/FictionArchive.Service.ReportingService/Models/DTOs/JobDto.cs @@ -0,0 +1,19 @@ +using FictionArchive.Common.Enums; +using HotChocolate.Data; +using NodaTime; + +namespace FictionArchive.Service.ReportingService.Models.DTOs; + +public class JobDto +{ + public Guid Id { get; init; } + public Instant CreatedTime { get; init; } + public Instant LastUpdatedTime { get; init; } + public Guid? ParentJobId { get; init; } + public required string JobType { get; init; } + public required string DisplayName { get; init; } + public JobStatus Status { get; init; } + public string? ErrorMessage { get; init; } + public Dictionary? Metadata { get; init; } + public IEnumerable? ChildJobs { get; init; } +} diff --git a/FictionArchive.Service.ReportingService/Program.cs b/FictionArchive.Service.ReportingService/Program.cs index 4f43e86..946420c 100644 --- a/FictionArchive.Service.ReportingService/Program.cs +++ b/FictionArchive.Service.ReportingService/Program.cs @@ -36,9 +36,8 @@ public class Program #region GraphQL builder.Services.AddGraphQLServer() - .AddQueryConventions() - .AddTypeExtension(typeof(JobQueries)) .ApplySaneDefaults() + .AddQueryType() .AddAuthorization(); #endregion diff --git a/FictionArchive.Service.ReportingService/Properties/launchSettings.json b/FictionArchive.Service.ReportingService/Properties/launchSettings.json index b6c8106..8ce2e5d 100644 --- a/FictionArchive.Service.ReportingService/Properties/launchSettings.json +++ b/FictionArchive.Service.ReportingService/Properties/launchSettings.json @@ -14,6 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, + "launchUrl": "graphql", "applicationUrl": "https://localhost:7310;http://localhost:5140", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/FictionArchive.Service.ReportingService/subgraph-config.json b/FictionArchive.Service.ReportingService/subgraph-config.json index 529f9a0..42aacbc 100644 --- a/FictionArchive.Service.ReportingService/subgraph-config.json +++ b/FictionArchive.Service.ReportingService/subgraph-config.json @@ -1,6 +1,6 @@ { "subgraph": "Reporting", "http": { - "baseAddress": "http://localhost:5140/graphql" + "baseAddress": "https://localhost:7310/graphql" } } diff --git a/FictionArchive.Service.SchedulerService/GraphQL/Query.cs b/FictionArchive.Service.SchedulerService/GraphQL/Query.cs index 2594569..31b54a4 100644 --- a/FictionArchive.Service.SchedulerService/GraphQL/Query.cs +++ b/FictionArchive.Service.SchedulerService/GraphQL/Query.cs @@ -8,7 +8,7 @@ namespace FictionArchive.Service.SchedulerService.GraphQL; public class Query { - public async Task> GetJobs(JobManagerService jobManager) + public async Task> GetScheduledJobs(JobManagerService jobManager) { return await jobManager.GetScheduledJobs(); } diff --git a/fictionarchive-web-astro/src/lib/components/JobFilters.svelte b/fictionarchive-web-astro/src/lib/components/JobFilters.svelte new file mode 100644 index 0000000..083bde3 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/JobFilters.svelte @@ -0,0 +1,217 @@ + + +
+ +
+ + handleSearchInput(e.currentTarget.value)} + class="pl-9" + /> +
+ + + handleStatusChange(v as string[])} + > + + + {filters.statuses.length > 0 ? selectedStatusLabels : 'Status'} + + + + + {#each JOB_STATUS_OPTIONS as option (option.value)} + + {#snippet children({ selected })} +
+ {#if selected} + + {/if} +
+ {option.label} + {/snippet} +
+ {/each} +
+
+ + + {#if availableJobTypes.length > 0} + v && handleJobTypeChange(v)} + > + + + {filters.jobType || 'Job Type'} + + + + + + {#snippet children({ selected })} +
+ {#if selected} + + {/if} +
+ All Types + {/snippet} +
+ {#each availableJobTypes as jobType (jobType)} + + {#snippet children({ selected })} +
+ {#if selected} + + {/if} +
+ {jobType} + {/snippet} +
+ {/each} +
+
+ {/if} + + + {#if hasActiveJobFilters(filters)} + + {/if} +
+ + +{#if hasActiveJobFilters(filters)} +
+ {#if filters.search} + + Search: {filters.search} + + + {/if} + + {#each filters.statuses as status (status)} + + {JOB_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status} + + + {/each} + + {#if filters.jobType} + + Type: {filters.jobType} + + + {/if} +
+{/if} diff --git a/fictionarchive-web-astro/src/lib/components/JobRow.svelte b/fictionarchive-web-astro/src/lib/components/JobRow.svelte new file mode 100644 index 0000000..8d83181 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/JobRow.svelte @@ -0,0 +1,168 @@ + + + + + + {#if hasChildren} + + {/if} + + {job.displayName} + {job.jobType} + + + + + {formatTime(job.createdTime)} + {formatTimeFull(job.createdTime)} + + + + + {#if hasChildren} + {children.length} sub-job{children.length !== 1 ? 's' : ''} + {/if} + + + + +{#if expanded} + + +
+ +
+ Last Updated + {formatTimeFull(job.lastUpdatedTime)} +
+ + {#if job.errorMessage} +
+ Error: + {job.errorMessage} +
+ {/if} + + + {#if metadata.length > 0} +
+

Metadata

+
+ {#each metadata as entry (entry.key)} + {entry.key} + {entry.value} + {/each} +
+
+ {/if} + + + {#if hasChildren} +
+

Sub-jobs

+ + + + + + + + + + + {#each children as child (child.id)} + + + + + + + {#if child.errorMessage} + + + + {/if} + {/each} + +
NameTypeStatusCreated
{child.displayName}{child.jobType} + + + {formatTime(child.createdTime)} + {formatTimeFull(child.createdTime)} + + +
+ {child.errorMessage} +
+
+ {/if} +
+ + +{/if} diff --git a/fictionarchive-web-astro/src/lib/components/JobStatusBadge.svelte b/fictionarchive-web-astro/src/lib/components/JobStatusBadge.svelte new file mode 100644 index 0000000..8dc7f6f --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/JobStatusBadge.svelte @@ -0,0 +1,20 @@ + + +{config.label} diff --git a/fictionarchive-web-astro/src/lib/components/JobsTab.svelte b/fictionarchive-web-astro/src/lib/components/JobsTab.svelte new file mode 100644 index 0000000..90dc129 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/JobsTab.svelte @@ -0,0 +1,179 @@ + + +
+ + + Filters + + + + + + + {#if fetching && initialLoad} + + +
+
+
+
+
+ {/if} + + {#if error} + + +

Could not load jobs: {error}

+
+
+ {/if} + + {#if !initialLoad && !error && jobs.length === 0} + + +

+ {#if hasActiveJobFilters(filters)} + No jobs match your filters. Try adjusting your search criteria. + {:else} + No jobs found. + {/if} +

+
+
+ {/if} + + {#if jobs.length > 0} + + + +
+ + Page {currentPage} + +
+ {/if} +
diff --git a/fictionarchive-web-astro/src/lib/components/JobsTable.svelte b/fictionarchive-web-astro/src/lib/components/JobsTable.svelte new file mode 100644 index 0000000..1167787 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/JobsTable.svelte @@ -0,0 +1,42 @@ + + +
+ + + + + + + + + + + + + {#each jobs as job (job.id)} + toggleRow(job.id)} + columnCount={COLUMN_COUNT} + /> + {/each} + +
NameTypeStatusCreatedSub-jobs
+
diff --git a/fictionarchive-web-astro/src/lib/components/Navbar.svelte b/fictionarchive-web-astro/src/lib/components/Navbar.svelte index 826c3c6..b430d50 100644 --- a/fictionarchive-web-astro/src/lib/components/Navbar.svelte +++ b/fictionarchive-web-astro/src/lib/components/Navbar.svelte @@ -29,7 +29,11 @@ Reading Lists + + Status + {/if} +
diff --git a/fictionarchive-web-astro/src/lib/components/StatusPage.svelte b/fictionarchive-web-astro/src/lib/components/StatusPage.svelte new file mode 100644 index 0000000..3af3b5a --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/StatusPage.svelte @@ -0,0 +1,21 @@ + + +
+
+

Status

+

Monitor jobs and system activity

+
+ + + + Jobs + + + + + + +
diff --git a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts index 4ba2090..bf09f18 100644 --- a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts +++ b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts @@ -257,6 +257,45 @@ export type InvitedUserDto = { username: Scalars['String']['output']; }; +export type JobDto = { + childJobs: Maybe>; + createdTime: Scalars['Instant']['output']; + displayName: Scalars['String']['output']; + errorMessage: Maybe; + id: Scalars['UUID']['output']; + jobType: Scalars['String']['output']; + lastUpdatedTime: Scalars['Instant']['output']; + metadata: Maybe>; + parentJobId: Maybe; + status: JobStatus; +}; + +export type JobDtoFilterInput = { + and?: InputMaybe>; + childJobs?: InputMaybe; + createdTime?: InputMaybe; + displayName?: InputMaybe; + errorMessage?: InputMaybe; + id?: InputMaybe; + jobType?: InputMaybe; + lastUpdatedTime?: InputMaybe; + metadata?: InputMaybe; + or?: InputMaybe>; + parentJobId?: InputMaybe; + status?: InputMaybe; +}; + +export type JobDtoSortInput = { + createdTime?: InputMaybe; + displayName?: InputMaybe; + errorMessage?: InputMaybe; + id?: InputMaybe; + jobType?: InputMaybe; + lastUpdatedTime?: InputMaybe; + parentJobId?: InputMaybe; + status?: InputMaybe; +}; + export type JobKey = { group: Scalars['String']['output']; name: Scalars['String']['output']; @@ -266,6 +305,39 @@ export type JobPersistenceError = Error & { message: Scalars['String']['output']; }; +export const JobStatus = { + Completed: 'COMPLETED', + Failed: 'FAILED', + InProgress: 'IN_PROGRESS', + Pending: 'PENDING' +} as const; + +export type JobStatus = typeof JobStatus[keyof typeof JobStatus]; +export type JobStatusOperationFilterInput = { + eq?: InputMaybe; + in?: InputMaybe>; + neq?: InputMaybe; + nin?: InputMaybe>; +}; + +/** A connection to a list of items. */ +export type JobsConnection = { + /** A list of edges. */ + edges: Maybe>; + /** A flattened list of the nodes. */ + nodes: Maybe>; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + +/** An edge in a connection. */ +export type JobsEdge = { + /** A cursor for use in pagination. */ + cursor: Scalars['String']['output']; + /** The item at the end of the edge. */ + node: JobDto; +}; + export type KeyNotFoundError = Error & { message: Scalars['String']['output']; }; @@ -275,6 +347,13 @@ export type KeyValuePairOfStringAndString = { value: Scalars['String']['output']; }; +export type KeyValuePairOfStringAndStringFilterInput = { + and?: InputMaybe>; + key?: InputMaybe; + or?: InputMaybe>; + value?: InputMaybe; +}; + export const Language = { Ch: 'CH', En: 'EN', @@ -304,6 +383,20 @@ export type ListFilterInputTypeOfImageDtoFilterInput = { some?: InputMaybe; }; +export type ListFilterInputTypeOfJobDtoFilterInput = { + all?: InputMaybe; + any?: InputMaybe; + none?: InputMaybe; + some?: InputMaybe; +}; + +export type ListFilterInputTypeOfKeyValuePairOfStringAndStringFilterInput = { + all?: InputMaybe; + any?: InputMaybe; + none?: InputMaybe; + some?: InputMaybe; +}; + export type ListFilterInputTypeOfNovelTagDtoFilterInput = { all?: InputMaybe; any?: InputMaybe; @@ -576,10 +669,12 @@ export type Query = { bookmarks: Array; chapter: Maybe; currentUser: Maybe; - jobs: Array; + jobById: Maybe; + jobs: Maybe; novels: Maybe; readingList: Maybe; readingLists: Array; + scheduledJobs: Array; translationEngines: Array; translationRequests: Maybe; }; @@ -598,6 +693,21 @@ export type QueryChapterArgs = { }; +export type QueryJobByIdArgs = { + jobId: Scalars['UUID']['input']; +}; + + +export type QueryJobsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + order?: InputMaybe>; + where?: InputMaybe; +}; + + export type QueryNovelsArgs = { after?: InputMaybe; before?: InputMaybe; @@ -1076,6 +1186,16 @@ export type GetChapterQueryVariables = Exact<{ export type GetChapterQuery = { chapter: { id: any, order: any, name: string, body: string, url: string | null, revision: any, createdTime: any, lastUpdatedTime: any, novelId: any, novelName: string, volumeId: any, volumeName: string, volumeOrder: number, totalChaptersInVolume: number, prevChapterVolumeOrder: number | null, prevChapterOrder: any | null, nextChapterVolumeOrder: number | null, nextChapterOrder: any | null, images: Array<{ id: any, newPath: string | null }> } | null }; +export type JobsQueryVariables = Exact<{ + first?: InputMaybe; + after?: InputMaybe; + where?: InputMaybe; + order?: InputMaybe | JobDtoSortInput>; +}>; + + +export type JobsQuery = { jobs: { edges: Array<{ cursor: string, node: { id: any, parentJobId: any | null, jobType: string, displayName: string, status: JobStatus, errorMessage: string | null, createdTime: any, lastUpdatedTime: any, metadata: Array<{ key: string, value: string }> | null, childJobs: Array<{ id: any, jobType: string, displayName: string, status: JobStatus, errorMessage: string | null, createdTime: any, lastUpdatedTime: any, metadata: Array<{ key: string, value: string }> | null }> | null } }> | null, pageInfo: { hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null, endCursor: string | null } } | null }; + export type NovelQueryVariables = Exact<{ id: Scalars['UnsignedInt']['input']; }>; @@ -1129,6 +1249,7 @@ export const UpdateReadingListDocument = {"kind":"Document","definitions":[{"kin export const UpsertBookmarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpsertBookmark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpsertBookmarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"upsertBookmark"},"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":"bookmark"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"chapterId"}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"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 GetBookmarksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBookmarks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"chapterId"}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}}]} as unknown as DocumentNode; export const GetChapterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapter"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"volumeOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"volumeOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"volumeOrder"}}},{"kind":"Argument","name":{"kind":"Name","value":"chapterOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"revision"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"novelName"}},{"kind":"Field","name":{"kind":"Name","value":"volumeId"}},{"kind":"Field","name":{"kind":"Name","value":"volumeName"}},{"kind":"Field","name":{"kind":"Name","value":"volumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"totalChaptersInVolume"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterVolumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterVolumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterOrder"}}]}}]}}]} as unknown as DocumentNode; +export const JobsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Jobs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JobDtoFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"order"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JobDtoSortInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"jobs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"Variable","name":{"kind":"Name","value":"order"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentJobId"}},{"kind":"Field","name":{"kind":"Name","value":"jobType"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"childJobs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"jobType"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; export const NovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"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":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rawLanguage"}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"statusOverride"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"externalUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"source"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"volumes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"order"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoSortInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"Variable","name":{"kind":"Name","value":"order"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"volumes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"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":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"addedTime"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/fictionarchive-web-astro/src/lib/graphql/queries/jobs.graphql b/fictionarchive-web-astro/src/lib/graphql/queries/jobs.graphql new file mode 100644 index 0000000..ab45458 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/graphql/queries/jobs.graphql @@ -0,0 +1,40 @@ +query Jobs($first: Int, $after: String, $where: JobDtoFilterInput, $order: [JobDtoSortInput!]) { + jobs(first: $first, after: $after, where: $where, order: $order) { + edges { + cursor + node { + id + parentJobId + jobType + displayName + status + errorMessage + metadata { + key + value + } + createdTime + lastUpdatedTime + childJobs { + id + jobType + displayName + status + errorMessage + metadata { + key + value + } + createdTime + lastUpdatedTime + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } +} diff --git a/fictionarchive-web-astro/src/lib/utils/jobFilterParams.ts b/fictionarchive-web-astro/src/lib/utils/jobFilterParams.ts new file mode 100644 index 0000000..81db1a5 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/utils/jobFilterParams.ts @@ -0,0 +1,131 @@ +import type { JobDtoFilterInput, JobDtoSortInput, JobStatus, SortEnumType } from '$lib/graphql/__generated__/graphql'; + +export type JobSortField = 'createdTime' | 'lastUpdatedTime' | 'status'; +export type JobSortDirection = SortEnumType; + +export interface JobSort { + field: JobSortField; + direction: JobSortDirection; +} + +export interface JobFilters { + search: string; + statuses: JobStatus[]; + jobType: string; + sort: JobSort; +} + +export const JOB_STATUS_OPTIONS: { value: JobStatus; label: string }[] = [ + { value: 'PENDING', label: 'Pending' }, + { value: 'IN_PROGRESS', label: 'In Progress' }, + { value: 'COMPLETED', label: 'Completed' }, + { value: 'FAILED', label: 'Failed' }, +]; + +export const DEFAULT_JOB_SORT: JobSort = { + field: 'createdTime', + direction: 'DESC', +}; + +export const EMPTY_JOB_FILTERS: JobFilters = { + search: '', + statuses: [], + jobType: '', + sort: DEFAULT_JOB_SORT, +}; + +const VALID_STATUSES: string[] = JOB_STATUS_OPTIONS.map((o) => o.value); +const VALID_SORT_FIELDS: JobSortField[] = ['createdTime', 'lastUpdatedTime', 'status']; +const VALID_SORT_DIRECTIONS: JobSortDirection[] = ['ASC', 'DESC']; + +export function parseJobFiltersFromURL(searchParams?: URLSearchParams): JobFilters { + const params = searchParams ?? new URLSearchParams(window.location.search); + + const search = params.get('search') ?? ''; + const jobType = params.get('jobType') ?? ''; + + const statusParam = params.get('status') ?? ''; + const statuses = statusParam + .split(',') + .filter((s) => s && VALID_STATUSES.includes(s)) as JobStatus[]; + + const sortField = params.get('sortBy') as JobSortField | null; + const sortDir = params.get('sortDir') as JobSortDirection | null; + const sort: JobSort = { + field: sortField && VALID_SORT_FIELDS.includes(sortField) ? sortField : DEFAULT_JOB_SORT.field, + direction: sortDir && VALID_SORT_DIRECTIONS.includes(sortDir) ? sortDir : DEFAULT_JOB_SORT.direction, + }; + + return { search, statuses, jobType, sort }; +} + +export function jobFiltersToURLParams(filters: JobFilters): string { + const params = new URLSearchParams(); + + if (filters.search.trim()) { + params.set('search', filters.search.trim()); + } + if (filters.statuses.length > 0) { + params.set('status', filters.statuses.join(',')); + } + if (filters.jobType.trim()) { + params.set('jobType', filters.jobType.trim()); + } + if (filters.sort.field !== DEFAULT_JOB_SORT.field || filters.sort.direction !== DEFAULT_JOB_SORT.direction) { + params.set('sortBy', filters.sort.field); + params.set('sortDir', filters.sort.direction); + } + + return params.toString(); +} + +export function syncJobFiltersToURL(filters: JobFilters): void { + const params = jobFiltersToURLParams(filters); + const newUrl = params ? `${window.location.pathname}?${params}` : window.location.pathname; + window.history.replaceState({}, '', newUrl); +} + +export function jobFiltersToGraphQLWhere(filters: JobFilters): JobDtoFilterInput | null { + const conditions: JobDtoFilterInput[] = []; + + if (filters.search.trim()) { + conditions.push({ + displayName: { contains: filters.search.trim() }, + }); + } + + if (filters.statuses.length > 0) { + conditions.push({ + status: { in: filters.statuses }, + }); + } + + if (filters.jobType.trim()) { + conditions.push({ + jobType: { eq: filters.jobType.trim() }, + }); + } + + // Always filter to top-level jobs only (no parent) + conditions.push({ + parentJobId: { eq: null }, + }); + + if (conditions.length === 1) { + return conditions[0]; + } + + return { and: conditions }; +} + +export function jobSortToGraphQLOrder(sort: JobSort): JobDtoSortInput[] { + return [{ [sort.field]: sort.direction }]; +} + +export function hasActiveJobFilters(filters: JobFilters): boolean { + return ( + filters.search.trim().length > 0 || + filters.statuses.length > 0 || + filters.jobType.trim().length > 0 + ); +} diff --git a/fictionarchive-web-astro/src/pages/status/index.astro b/fictionarchive-web-astro/src/pages/status/index.astro new file mode 100644 index 0000000..592a8b7 --- /dev/null +++ b/fictionarchive-web-astro/src/pages/status/index.astro @@ -0,0 +1,8 @@ +--- +import AppLayout from '../../layouts/AppLayout.astro'; +import StatusPage from '../../lib/components/StatusPage.svelte'; +--- + + + + -- 2.49.1 From 4264051d11d1570d53fb388bb7b1f5039602b486 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 1 Feb 2026 12:10:18 -0500 Subject: [PATCH 14/14] [FA-misc] Fix lint issues --- .../src/lib/components/JobsTab.svelte | 12 ++++-------- .../src/lib/components/JobsTable.svelte | 5 ++++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/fictionarchive-web-astro/src/lib/components/JobsTab.svelte b/fictionarchive-web-astro/src/lib/components/JobsTab.svelte index 90dc129..37e899b 100644 --- a/fictionarchive-web-astro/src/lib/components/JobsTab.svelte +++ b/fictionarchive-web-astro/src/lib/components/JobsTab.svelte @@ -37,13 +37,9 @@ const jobs = $derived((edges ?? []).map((edge) => edge.node).filter(Boolean)); // Extract unique job types from loaded data for the filter dropdown - const availableJobTypes = $derived(() => { - const types = new Set(); - for (const job of jobs) { - types.add(job.jobType); - } - return Array.from(types).sort(); - }); + const availableJobTypes = $derived( + [...new Set(jobs.map((job) => job.jobType))].sort() + ); async function fetchJobs(after: string | null = null) { fetching = true; @@ -123,7 +119,7 @@ Filters - + diff --git a/fictionarchive-web-astro/src/lib/components/JobsTable.svelte b/fictionarchive-web-astro/src/lib/components/JobsTable.svelte index 1167787..8cfcf80 100644 --- a/fictionarchive-web-astro/src/lib/components/JobsTable.svelte +++ b/fictionarchive-web-astro/src/lib/components/JobsTable.svelte @@ -1,8 +1,11 @@