From 5013da69c21f7e871e695fb549ea8ee59fe29ef0 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 29 Dec 2025 14:54:01 -0500 Subject: [PATCH 1/8] [FA-27] UserNovelDataService bootstrapped, going to do author's posts first i think --- .../Dockerfile | 23 ++++++ ...rchive.Service.UserNovelDataService.csproj | 20 +++++ .../GraphQL/Mutation.cs | 6 ++ .../GraphQL/Query.cs | 6 ++ .../Program.cs | 75 +++++++++++++++++++ .../Properties/launchSettings.json | 38 ++++++++++ .../Services/UserNovelDataServiceDbContext.cs | 11 +++ .../appsettings.Development.json | 8 ++ .../appsettings.json | 16 ++++ FictionArchive.sln | 8 +- 10 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 FictionArchive.Service.UserNovelDataService/Dockerfile create mode 100644 FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj create mode 100644 FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs create mode 100644 FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Program.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Properties/launchSettings.json create mode 100644 FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs create mode 100644 FictionArchive.Service.UserNovelDataService/appsettings.Development.json create mode 100644 FictionArchive.Service.UserNovelDataService/appsettings.json diff --git a/FictionArchive.Service.UserNovelDataService/Dockerfile b/FictionArchive.Service.UserNovelDataService/Dockerfile new file mode 100644 index 0000000..8d7ad0d --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/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.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj", "FictionArchive.Service.UserNovelDataService/"] +RUN dotnet restore "FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj" +COPY . . +WORKDIR "/src/FictionArchive.Service.UserNovelDataService" +RUN dotnet build "./FictionArchive.Service.UserNovelDataService.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./FictionArchive.Service.UserNovelDataService.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.UserNovelDataService.dll"] diff --git a/FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj b/FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj new file mode 100644 index 0000000..6bee3ff --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + diff --git a/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs b/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs new file mode 100644 index 0000000..5810b81 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs @@ -0,0 +1,6 @@ +namespace FictionArchive.Service.UserNovelDataService.GraphQL; + +public class Mutation +{ + +} \ No newline at end of file diff --git a/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs b/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs new file mode 100644 index 0000000..7ad2845 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs @@ -0,0 +1,6 @@ +namespace FictionArchive.Service.UserNovelDataService.GraphQL; + +public class Query +{ + +} \ No newline at end of file diff --git a/FictionArchive.Service.UserNovelDataService/Program.cs b/FictionArchive.Service.UserNovelDataService/Program.cs new file mode 100644 index 0000000..166735f --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Program.cs @@ -0,0 +1,75 @@ +using FictionArchive.Common.Extensions; +using FictionArchive.Service.Shared; +using FictionArchive.Service.Shared.Extensions; +using FictionArchive.Service.Shared.Services.EventBus.Implementations; +using FictionArchive.Service.UserNovelDataService.GraphQL; +using FictionArchive.Service.UserNovelDataService.Services; + +namespace FictionArchive.Service.UserNovelDataService; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args); + + builder.AddLocalAppsettings(); + + builder.Services.AddMemoryCache(); + builder.Services.AddHealthChecks(); + + #region Event Bus + + if (!isSchemaExport) + { + builder.Services.AddRabbitMQ(opt => + { + builder.Configuration.GetSection("RabbitMQ").Bind(opt); + }); + } + + #endregion + + #region GraphQL + + builder.Services.AddDefaultGraphQl() + .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(); + + // 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); + } +} \ No newline at end of file diff --git a/FictionArchive.Service.UserNovelDataService/Properties/launchSettings.json b/FictionArchive.Service.UserNovelDataService/Properties/launchSettings.json new file mode 100644 index 0000000..7d694ee --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:26318", + "sslPort": 44303 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7298;http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs b/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs new file mode 100644 index 0000000..78383ca --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs @@ -0,0 +1,11 @@ +using FictionArchive.Service.Shared.Services.Database; +using Microsoft.EntityFrameworkCore; + +namespace FictionArchive.Service.UserNovelDataService.Services; + +public class UserNovelDataServiceDbContext : FictionArchiveDbContext +{ + public UserNovelDataServiceDbContext(DbContextOptions options, ILogger logger) : base(options, logger) + { + } +} \ No newline at end of file diff --git a/FictionArchive.Service.UserNovelDataService/appsettings.Development.json b/FictionArchive.Service.UserNovelDataService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/FictionArchive.Service.UserNovelDataService/appsettings.json b/FictionArchive.Service.UserNovelDataService/appsettings.json new file mode 100644 index 0000000..baea518 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=FictionArchive_UserNovelDataService;Username=postgres;password=postgres" + }, + "RabbitMQ": { + "ConnectionString": "amqp://localhost", + "ClientIdentifier": "UserNovelDataService" + }, + "AllowedHosts": "*" +} diff --git a/FictionArchive.sln b/FictionArchive.sln index 6a0e6ef..8d09302 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}" @@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Nove EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserService.Tests", "FictionArchive.Service.UserService.Tests\FictionArchive.Service.UserService.Tests.csproj", "{10C38C89-983D-4544-8911-F03099F66AB8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserNovelDataService", "FictionArchive.Service.UserNovelDataService\FictionArchive.Service.UserNovelDataService.csproj", "{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,5 +69,9 @@ Global {10C38C89-983D-4544-8911-F03099F66AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU {10C38C89-983D-4544-8911-F03099F66AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU {10C38C89-983D-4544-8911-F03099F66AB8}.Release|Any CPU.Build.0 = Release|Any CPU + {A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 EndGlobalSection EndGlobal From f8a45ad891963fab96f4f9ab5761c10f89696161 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 19 Jan 2026 00:01:16 -0500 Subject: [PATCH 2/8] [FA-27] Bookmark implementation --- ...rchive.Service.UserNovelDataService.csproj | 7 + .../GraphQL/Mutation.cs | 107 ++++++++++- .../GraphQL/Query.cs | 43 ++++- .../20251230181559_AddBookmarks.Designer.cs | 99 ++++++++++ .../Migrations/20251230181559_AddBookmarks.cs | 76 ++++++++ ...rNovelDataServiceDbContextModelSnapshot.cs | 96 ++++++++++ .../Models/DTOs/BookmarkDto.cs | 12 ++ .../Models/DTOs/BookmarkPayload.cs | 7 + .../Models/DTOs/UpsertBookmarkInput.cs | 3 + .../Models/Database/Bookmark.cs | 14 ++ .../Models/Database/Chapter.cs | 8 + .../Models/Database/Novel.cs | 8 + .../Models/Database/User.cs | 8 + .../Models/Database/Volume.cs | 8 + .../Services/UserNovelDataServiceDbContext.cs | 26 ++- .../appsettings.json | 10 + .../subgraph-config.json | 6 + .../lib/components/ChapterNavigation.svelte | 174 +++++++++++++++++- .../lib/components/ChapterReaderPage.svelte | 47 ++++- .../src/lib/components/NovelDetailPage.svelte | 100 +++++++++- .../src/lib/components/ui/popover/index.ts | 18 ++ .../ui/popover/popover-content.svelte | 26 +++ .../src/lib/components/ui/textarea/index.ts | 7 + .../components/ui/textarea/textarea.svelte | 27 +++ .../src/lib/graphql/__generated__/graphql.ts | 79 ++++++++ .../graphql/mutations/removeBookmark.graphql | 12 ++ .../graphql/mutations/upsertBookmark.graphql | 19 ++ .../src/lib/graphql/queries/bookmarks.graphql | 9 + 28 files changed, 1036 insertions(+), 20 deletions(-) create mode 100644 FictionArchive.Service.UserNovelDataService/Migrations/20251230181559_AddBookmarks.Designer.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Migrations/20251230181559_AddBookmarks.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/DTOs/BookmarkDto.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/DTOs/BookmarkPayload.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/DTOs/UpsertBookmarkInput.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/Database/Bookmark.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/Database/Chapter.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/Database/Novel.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/Database/User.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/Database/Volume.cs create mode 100644 FictionArchive.Service.UserNovelDataService/subgraph-config.json create mode 100644 fictionarchive-web-astro/src/lib/components/ui/popover/index.ts create mode 100644 fictionarchive-web-astro/src/lib/components/ui/popover/popover-content.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/ui/textarea/index.ts create mode 100644 fictionarchive-web-astro/src/lib/components/ui/textarea/textarea.svelte create mode 100644 fictionarchive-web-astro/src/lib/graphql/mutations/removeBookmark.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/mutations/upsertBookmark.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/queries/bookmarks.graphql diff --git a/FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj b/FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj index 6bee3ff..e486c8c 100644 --- a/FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj +++ b/FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj @@ -7,6 +7,13 @@ Linux + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + .dockerignore diff --git a/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs b/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs index 5810b81..09a46e1 100644 --- a/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs @@ -1,6 +1,109 @@ +using System.Security.Claims; +using FictionArchive.Service.UserNovelDataService.Models.Database; +using FictionArchive.Service.UserNovelDataService.Models.DTOs; +using FictionArchive.Service.UserNovelDataService.Services; +using HotChocolate.Authorization; +using HotChocolate.Types; +using Microsoft.EntityFrameworkCore; + namespace FictionArchive.Service.UserNovelDataService.GraphQL; public class Mutation { - -} \ No newline at end of file + [Authorize] + [Error] + public async Task UpsertBookmark( + UserNovelDataServiceDbContext dbContext, + ClaimsPrincipal claimsPrincipal, + UpsertBookmarkInput input) + { + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + throw new InvalidOperationException("Unable to determine current user identity"); + } + + var user = await dbContext.Users + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + + if (user == null) + { + // Auto-create user if not exists + user = new User { OAuthProviderId = oAuthProviderId }; + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + } + + var existingBookmark = await dbContext.Bookmarks + .FirstOrDefaultAsync(b => b.UserId == user.Id && b.ChapterId == input.ChapterId); + + if (existingBookmark != null) + { + // Update existing + existingBookmark.Description = input.Description; + } + else + { + // Create new + existingBookmark = new Bookmark + { + UserId = user.Id, + NovelId = input.NovelId, + ChapterId = input.ChapterId, + Description = input.Description + }; + dbContext.Bookmarks.Add(existingBookmark); + } + + await dbContext.SaveChangesAsync(); + + return new BookmarkPayload + { + Success = true, + Bookmark = new BookmarkDto + { + Id = existingBookmark.Id, + ChapterId = existingBookmark.ChapterId, + NovelId = existingBookmark.NovelId, + Description = existingBookmark.Description, + CreatedTime = existingBookmark.CreatedTime + } + }; + } + + [Authorize] + [Error] + public async Task RemoveBookmark( + UserNovelDataServiceDbContext dbContext, + ClaimsPrincipal claimsPrincipal, + uint chapterId) + { + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + throw new InvalidOperationException("Unable to determine current user identity"); + } + + var user = await dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + + if (user == null) + { + return new BookmarkPayload { Success = false }; + } + + var bookmark = await dbContext.Bookmarks + .FirstOrDefaultAsync(b => b.UserId == user.Id && b.ChapterId == chapterId); + + if (bookmark == null) + { + return new BookmarkPayload { Success = false }; + } + + dbContext.Bookmarks.Remove(bookmark); + await dbContext.SaveChangesAsync(); + + return new BookmarkPayload { Success = true }; + } +} diff --git a/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs b/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs index 7ad2845..0b6e297 100644 --- a/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs +++ b/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs @@ -1,6 +1,45 @@ +using System.Security.Claims; +using FictionArchive.Service.UserNovelDataService.Models.DTOs; +using FictionArchive.Service.UserNovelDataService.Services; +using HotChocolate.Authorization; +using Microsoft.EntityFrameworkCore; + namespace FictionArchive.Service.UserNovelDataService.GraphQL; public class Query { - -} \ No newline at end of file + [Authorize] + public async Task> GetBookmarks( + UserNovelDataServiceDbContext dbContext, + ClaimsPrincipal claimsPrincipal, + uint novelId) + { + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + return new List().AsQueryable(); + } + + var user = await dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + + if (user == null) + { + return new List().AsQueryable(); + } + + return dbContext.Bookmarks + .AsNoTracking() + .Where(b => b.UserId == user.Id && b.NovelId == novelId) + .OrderByDescending(b => b.CreatedTime) + .Select(b => new BookmarkDto + { + Id = b.Id, + ChapterId = b.ChapterId, + NovelId = b.NovelId, + Description = b.Description, + CreatedTime = b.CreatedTime + }); + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Migrations/20251230181559_AddBookmarks.Designer.cs b/FictionArchive.Service.UserNovelDataService/Migrations/20251230181559_AddBookmarks.Designer.cs new file mode 100644 index 0000000..fdb9d07 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Migrations/20251230181559_AddBookmarks.Designer.cs @@ -0,0 +1,99 @@ +// +using System; +using FictionArchive.Service.UserNovelDataService.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.UserNovelDataService.Migrations +{ + [DbContext(typeof(UserNovelDataServiceDbContext))] + [Migration("20251230181559_AddBookmarks")] + partial class AddBookmarks + { + /// + 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.UserNovelDataService.Models.Database.Bookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChapterId") + .HasColumnType("bigint"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ChapterId") + .IsUnique(); + + b.HasIndex("UserId", "NovelId"); + + b.ToTable("Bookmarks"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("OAuthProviderId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Migrations/20251230181559_AddBookmarks.cs b/FictionArchive.Service.UserNovelDataService/Migrations/20251230181559_AddBookmarks.cs new file mode 100644 index 0000000..f987bda --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Migrations/20251230181559_AddBookmarks.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FictionArchive.Service.UserNovelDataService.Migrations +{ + /// + public partial class AddBookmarks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OAuthProviderId = table.Column(type: "text", nullable: false), + 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_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Bookmarks", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "uuid", nullable: false), + ChapterId = table.Column(type: "bigint", nullable: false), + NovelId = table.Column(type: "bigint", nullable: false), + Description = table.Column(type: "text", 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_Bookmarks", x => x.Id); + table.ForeignKey( + name: "FK_Bookmarks_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Bookmarks_UserId_ChapterId", + table: "Bookmarks", + columns: new[] { "UserId", "ChapterId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Bookmarks_UserId_NovelId", + table: "Bookmarks", + columns: new[] { "UserId", "NovelId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Bookmarks"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs b/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs new file mode 100644 index 0000000..1e50402 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs @@ -0,0 +1,96 @@ +// +using System; +using FictionArchive.Service.UserNovelDataService.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.UserNovelDataService.Migrations +{ + [DbContext(typeof(UserNovelDataServiceDbContext))] + partial class UserNovelDataServiceDbContextModelSnapshot : 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.UserNovelDataService.Models.Database.Bookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChapterId") + .HasColumnType("bigint"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ChapterId") + .IsUnique(); + + b.HasIndex("UserId", "NovelId"); + + b.ToTable("Bookmarks"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("OAuthProviderId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/DTOs/BookmarkDto.cs b/FictionArchive.Service.UserNovelDataService/Models/DTOs/BookmarkDto.cs new file mode 100644 index 0000000..844eaca --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/DTOs/BookmarkDto.cs @@ -0,0 +1,12 @@ +using NodaTime; + +namespace FictionArchive.Service.UserNovelDataService.Models.DTOs; + +public class BookmarkDto +{ + public int Id { get; init; } + public uint ChapterId { get; init; } + public uint NovelId { get; init; } + public string? Description { get; init; } + public Instant CreatedTime { get; init; } +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/DTOs/BookmarkPayload.cs b/FictionArchive.Service.UserNovelDataService/Models/DTOs/BookmarkPayload.cs new file mode 100644 index 0000000..cfc334f --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/DTOs/BookmarkPayload.cs @@ -0,0 +1,7 @@ +namespace FictionArchive.Service.UserNovelDataService.Models.DTOs; + +public class BookmarkPayload +{ + public BookmarkDto? Bookmark { get; init; } + public bool Success { get; init; } +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/DTOs/UpsertBookmarkInput.cs b/FictionArchive.Service.UserNovelDataService/Models/DTOs/UpsertBookmarkInput.cs new file mode 100644 index 0000000..06cfebb --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/DTOs/UpsertBookmarkInput.cs @@ -0,0 +1,3 @@ +namespace FictionArchive.Service.UserNovelDataService.Models.DTOs; + +public record UpsertBookmarkInput(uint NovelId, uint ChapterId, string? Description); diff --git a/FictionArchive.Service.UserNovelDataService/Models/Database/Bookmark.cs b/FictionArchive.Service.UserNovelDataService/Models/Database/Bookmark.cs new file mode 100644 index 0000000..9a7e836 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/Bookmark.cs @@ -0,0 +1,14 @@ +using FictionArchive.Service.Shared.Models; + +namespace FictionArchive.Service.UserNovelDataService.Models.Database; + +public class Bookmark : BaseEntity +{ + public Guid UserId { get; set; } + public virtual User User { get; set; } = null!; + + public uint ChapterId { get; set; } + public uint NovelId { get; set; } + + public string? Description { get; set; } +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/Database/Chapter.cs b/FictionArchive.Service.UserNovelDataService/Models/Database/Chapter.cs new file mode 100644 index 0000000..1cc7e91 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/Chapter.cs @@ -0,0 +1,8 @@ +using FictionArchive.Service.Shared.Models; + +namespace FictionArchive.Service.UserNovelDataService.Models.Database; + +public class Chapter : BaseEntity +{ + +} \ No newline at end of file diff --git a/FictionArchive.Service.UserNovelDataService/Models/Database/Novel.cs b/FictionArchive.Service.UserNovelDataService/Models/Database/Novel.cs new file mode 100644 index 0000000..fc979d8 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/Novel.cs @@ -0,0 +1,8 @@ +using FictionArchive.Service.Shared.Models; + +namespace FictionArchive.Service.UserNovelDataService.Models.Database; + +public class Novel : BaseEntity +{ + public virtual ICollection Volumes { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.Service.UserNovelDataService/Models/Database/User.cs b/FictionArchive.Service.UserNovelDataService/Models/Database/User.cs new file mode 100644 index 0000000..9863b97 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/User.cs @@ -0,0 +1,8 @@ +using FictionArchive.Service.Shared.Models; + +namespace FictionArchive.Service.UserNovelDataService.Models.Database; + +public class User : BaseEntity +{ + public required string OAuthProviderId { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.Service.UserNovelDataService/Models/Database/Volume.cs b/FictionArchive.Service.UserNovelDataService/Models/Database/Volume.cs new file mode 100644 index 0000000..f5b0293 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/Volume.cs @@ -0,0 +1,8 @@ +using FictionArchive.Service.Shared.Models; + +namespace FictionArchive.Service.UserNovelDataService.Models.Database; + +public class Volume : BaseEntity +{ + public virtual ICollection Chapters { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs b/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs index 78383ca..38eb08c 100644 --- a/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs +++ b/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs @@ -1,11 +1,35 @@ using FictionArchive.Service.Shared.Services.Database; +using FictionArchive.Service.UserNovelDataService.Models.Database; using Microsoft.EntityFrameworkCore; namespace FictionArchive.Service.UserNovelDataService.Services; public class UserNovelDataServiceDbContext : FictionArchiveDbContext { + public DbSet Users { get; set; } + public DbSet Bookmarks { get; set; } + public UserNovelDataServiceDbContext(DbContextOptions options, ILogger logger) : base(options, logger) { } -} \ No newline at end of file + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + // Unique constraint: one bookmark per chapter per user + entity.HasIndex(b => new { b.UserId, b.ChapterId }).IsUnique(); + + // Index for efficient "get bookmarks for novel" queries + entity.HasIndex(b => new { b.UserId, b.NovelId }); + + // User relationship + entity.HasOne(b => b.User) + .WithMany() + .HasForeignKey(b => b.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/FictionArchive.Service.UserNovelDataService/appsettings.json b/FictionArchive.Service.UserNovelDataService/appsettings.json index baea518..425535a 100644 --- a/FictionArchive.Service.UserNovelDataService/appsettings.json +++ b/FictionArchive.Service.UserNovelDataService/appsettings.json @@ -12,5 +12,15 @@ "ConnectionString": "amqp://localhost", "ClientIdentifier": "UserNovelDataService" }, + "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.UserNovelDataService/subgraph-config.json b/FictionArchive.Service.UserNovelDataService/subgraph-config.json new file mode 100644 index 0000000..200a4f4 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/subgraph-config.json @@ -0,0 +1,6 @@ +{ + "subgraph": "UserNovelData", + "http": { + "baseAddress": "https://localhost:7298/graphql" + } +} \ No newline at end of file diff --git a/fictionarchive-web-astro/src/lib/components/ChapterNavigation.svelte b/fictionarchive-web-astro/src/lib/components/ChapterNavigation.svelte index 6f547fc..f7eadc4 100644 --- a/fictionarchive-web-astro/src/lib/components/ChapterNavigation.svelte +++ b/fictionarchive-web-astro/src/lib/components/ChapterNavigation.svelte @@ -1,29 +1,131 @@
@@ -38,10 +140,72 @@ - +
+ + + {#if chapterId} + + + {#snippet child({ props })} + + {/snippet} + + +
+
+

+ {isBookmarked ? 'Edit bookmark' : 'Bookmark this chapter'} +

+

+ {isBookmarked ? 'Update your note or remove the bookmark.' : 'Add an optional note to remember why you bookmarked this.'} +

+
+ diff --git a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts index a003e28..6fa4e20 100644 --- a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts +++ b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts @@ -29,6 +29,19 @@ export const ApplyPolicy = { } as const; export type ApplyPolicy = typeof ApplyPolicy[keyof typeof ApplyPolicy]; +export type BookmarkDto = { + chapterId: Scalars['UnsignedInt']['output']; + createdTime: Scalars['Instant']['output']; + description: Maybe; + id: Scalars['Int']['output']; + novelId: Scalars['UnsignedInt']['output']; +}; + +export type BookmarkPayload = { + bookmark: Maybe; + success: Scalars['Boolean']['output']; +}; + export type ChapterDto = { body: Scalars['String']['output']; createdTime: Scalars['Instant']['output']; @@ -266,9 +279,11 @@ export type Mutation = { fetchChapterContents: FetchChapterContentsPayload; importNovel: ImportNovelPayload; inviteUser: InviteUserPayload; + removeBookmark: RemoveBookmarkPayload; runJob: RunJobPayload; scheduleEventJob: ScheduleEventJobPayload; translateText: TranslateTextPayload; + upsertBookmark: UpsertBookmarkPayload; }; @@ -297,6 +312,11 @@ export type MutationInviteUserArgs = { }; +export type MutationRemoveBookmarkArgs = { + input: RemoveBookmarkInput; +}; + + export type MutationRunJobArgs = { input: RunJobInput; }; @@ -311,6 +331,11 @@ export type MutationTranslateTextArgs = { input: TranslateTextInput; }; + +export type MutationUpsertBookmarkArgs = { + input: UpsertBookmarkInput; +}; + export type NovelDto = { author: PersonDto; coverImage: Maybe; @@ -471,6 +496,7 @@ export type PersonDtoSortInput = { }; export type Query = { + bookmarks: Array; chapter: Maybe; currentUser: Maybe; jobs: Array; @@ -480,6 +506,11 @@ export type Query = { }; +export type QueryBookmarksArgs = { + novelId: Scalars['UnsignedInt']['input']; +}; + + export type QueryChapterArgs = { chapterOrder: Scalars['UnsignedInt']['input']; novelId: Scalars['UnsignedInt']['input']; @@ -514,6 +545,17 @@ export type QueryTranslationRequestsArgs = { where?: InputMaybe; }; +export type RemoveBookmarkError = InvalidOperationError; + +export type RemoveBookmarkInput = { + chapterId: Scalars['UnsignedInt']['input']; +}; + +export type RemoveBookmarkPayload = { + bookmarkPayload: Maybe; + errors: Maybe>; +}; + export type RunJobError = JobPersistenceError; export type RunJobInput = { @@ -739,6 +781,19 @@ export type UnsignedIntOperationFilterInputType = { nlte?: InputMaybe; }; +export type UpsertBookmarkError = InvalidOperationError; + +export type UpsertBookmarkInput = { + chapterId: Scalars['UnsignedInt']['input']; + description?: InputMaybe; + novelId: Scalars['UnsignedInt']['input']; +}; + +export type UpsertBookmarkPayload = { + bookmarkPayload: Maybe; + errors: Maybe>; +}; + export type UserDto = { availableInvites: Scalars['Int']['output']; createdTime: Scalars['Instant']['output']; @@ -807,6 +862,27 @@ export type InviteUserMutationVariables = Exact<{ export type InviteUserMutation = { inviteUser: { userDto: { id: any, username: string, email: string } | null, errors: Array<{ message: string }> | null } }; +export type RemoveBookmarkMutationVariables = Exact<{ + input: RemoveBookmarkInput; +}>; + + +export type RemoveBookmarkMutation = { removeBookmark: { bookmarkPayload: { success: boolean } | null, errors: Array<{ message: string }> | null } }; + +export type UpsertBookmarkMutationVariables = Exact<{ + input: UpsertBookmarkInput; +}>; + + +export type UpsertBookmarkMutation = { upsertBookmark: { bookmarkPayload: { success: boolean, bookmark: { id: number, chapterId: any, novelId: any, description: string | null, createdTime: any } | null } | null, errors: Array<{ message: string }> | null } }; + +export type GetBookmarksQueryVariables = Exact<{ + novelId: Scalars['UnsignedInt']['input']; +}>; + + +export type GetBookmarksQuery = { bookmarks: Array<{ id: number, chapterId: any, novelId: any, description: string | null, createdTime: any }> }; + export type GetChapterQueryVariables = Exact<{ novelId: Scalars['UnsignedInt']['input']; volumeOrder: Scalars['UnsignedInt']['input']; @@ -842,6 +918,9 @@ export type GetSettingsPageDataQuery = { currentUser: { id: any, username: strin export const DeleteNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boolean"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUpdateRequestedEvent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}}]}}]}}]} as unknown as DocumentNode; export const InviteUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InviteUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InviteUserInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userDto"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InvalidOperationError"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const RemoveBookmarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveBookmark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveBookmarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeBookmark"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarkPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const 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 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; diff --git a/fictionarchive-web-astro/src/lib/graphql/mutations/removeBookmark.graphql b/fictionarchive-web-astro/src/lib/graphql/mutations/removeBookmark.graphql new file mode 100644 index 0000000..d46852d --- /dev/null +++ b/fictionarchive-web-astro/src/lib/graphql/mutations/removeBookmark.graphql @@ -0,0 +1,12 @@ +mutation RemoveBookmark($input: RemoveBookmarkInput!) { + removeBookmark(input: $input) { + bookmarkPayload { + success + } + errors { + ... on Error { + message + } + } + } +} diff --git a/fictionarchive-web-astro/src/lib/graphql/mutations/upsertBookmark.graphql b/fictionarchive-web-astro/src/lib/graphql/mutations/upsertBookmark.graphql new file mode 100644 index 0000000..b5da4ba --- /dev/null +++ b/fictionarchive-web-astro/src/lib/graphql/mutations/upsertBookmark.graphql @@ -0,0 +1,19 @@ +mutation UpsertBookmark($input: UpsertBookmarkInput!) { + upsertBookmark(input: $input) { + bookmarkPayload { + success + bookmark { + id + chapterId + novelId + description + createdTime + } + } + errors { + ... on Error { + message + } + } + } +} diff --git a/fictionarchive-web-astro/src/lib/graphql/queries/bookmarks.graphql b/fictionarchive-web-astro/src/lib/graphql/queries/bookmarks.graphql new file mode 100644 index 0000000..543ea9b --- /dev/null +++ b/fictionarchive-web-astro/src/lib/graphql/queries/bookmarks.graphql @@ -0,0 +1,9 @@ +query GetBookmarks($novelId: UnsignedInt!) { + bookmarks(novelId: $novelId) { + id + chapterId + novelId + description + createdTime + } +} From 19ae4a8089b48911f9a98f0c99877ddfbdbcfd45 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 19 Jan 2026 01:36:10 -0500 Subject: [PATCH 3/8] Add .worktrees/ to .gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6455a38..8131f1e 100644 --- a/.gitignore +++ b/.gitignore @@ -139,4 +139,7 @@ appsettings.Local.json # Fusion Builds schema.graphql *.fsp -gateway.fgp \ No newline at end of file +gateway.fgp + +# Git worktrees +.worktrees/ \ No newline at end of file From 1ecfd9cc99a54b8c8f73fd768a714d9dca3843e7 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 19 Jan 2026 15:13:14 -0500 Subject: [PATCH 4/8] [FA-27] Need to test events but seems to mostly work --- .../IntegrationEvents/ChapterCreatedEvent.cs | 13 ++ .../IntegrationEvents/NovelCreatedEvent.cs | 13 ++ .../Services/NovelUpdateService.cs | 36 ++++ ...19184741_AddNovelVolumeChapter.Designer.cs | 198 ++++++++++++++++++ .../20260119184741_AddNovelVolumeChapter.cs | 95 +++++++++ ...rNovelDataServiceDbContextModelSnapshot.cs | 99 +++++++++ .../Models/Database/Chapter.cs | 3 +- .../Models/Database/Novel.cs | 2 +- .../Models/Database/Volume.cs | 4 +- .../IntegrationEvents/ChapterCreatedEvent.cs | 13 ++ .../IntegrationEvents/NovelCreatedEvent.cs | 13 ++ .../IntegrationEvents/UserInvitedEvent.cs | 15 ++ .../Program.cs | 7 +- .../Scripts/00_README.md | 93 ++++++++ .../01_extract_users_from_userservice.sql | 28 +++ .../02_extract_novels_from_novelservice.sql | 24 +++ .../03_extract_volumes_from_novelservice.sql | 26 +++ .../04_extract_chapters_from_novelservice.sql | 26 +++ ...5_insert_users_to_usernoveldataservice.sql | 32 +++ ..._insert_novels_to_usernoveldataservice.sql | 31 +++ ...insert_volumes_to_usernoveldataservice.sql | 34 +++ ...nsert_chapters_to_usernoveldataservice.sql | 34 +++ .../ChapterCreatedEventHandler.cs | 53 +++++ .../EventHandlers/NovelCreatedEventHandler.cs | 36 ++++ .../EventHandlers/UserInvitedEventHandler.cs | 40 ++++ .../Services/UserNovelDataServiceDbContext.cs | 3 + 26 files changed, 967 insertions(+), 4 deletions(-) create mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterCreatedEvent.cs create mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelCreatedEvent.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Migrations/20260119184741_AddNovelVolumeChapter.Designer.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Migrations/20260119184741_AddNovelVolumeChapter.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/ChapterCreatedEvent.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/NovelCreatedEvent.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/UserInvitedEvent.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Scripts/00_README.md create mode 100644 FictionArchive.Service.UserNovelDataService/Scripts/01_extract_users_from_userservice.sql create mode 100644 FictionArchive.Service.UserNovelDataService/Scripts/02_extract_novels_from_novelservice.sql create mode 100644 FictionArchive.Service.UserNovelDataService/Scripts/03_extract_volumes_from_novelservice.sql create mode 100644 FictionArchive.Service.UserNovelDataService/Scripts/04_extract_chapters_from_novelservice.sql create mode 100644 FictionArchive.Service.UserNovelDataService/Scripts/05_insert_users_to_usernoveldataservice.sql create mode 100644 FictionArchive.Service.UserNovelDataService/Scripts/06_insert_novels_to_usernoveldataservice.sql create mode 100644 FictionArchive.Service.UserNovelDataService/Scripts/07_insert_volumes_to_usernoveldataservice.sql create mode 100644 FictionArchive.Service.UserNovelDataService/Scripts/08_insert_chapters_to_usernoveldataservice.sql create mode 100644 FictionArchive.Service.UserNovelDataService/Services/EventHandlers/ChapterCreatedEventHandler.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Services/EventHandlers/NovelCreatedEventHandler.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Services/EventHandlers/UserInvitedEventHandler.cs diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterCreatedEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterCreatedEvent.cs new file mode 100644 index 0000000..3608c2f --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterCreatedEvent.cs @@ -0,0 +1,13 @@ +using FictionArchive.Service.Shared.Services.EventBus; + +namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; + +public class ChapterCreatedEvent : IIntegrationEvent +{ + public required uint ChapterId { get; init; } + public required uint NovelId { get; init; } + public required uint VolumeId { get; init; } + public required int VolumeOrder { get; init; } + public required uint ChapterOrder { get; init; } + public required string ChapterTitle { get; init; } +} diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelCreatedEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelCreatedEvent.cs new file mode 100644 index 0000000..50ede95 --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/IntegrationEvents/NovelCreatedEvent.cs @@ -0,0 +1,13 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.Shared.Services.EventBus; + +namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; + +public class NovelCreatedEvent : IIntegrationEvent +{ + public required uint NovelId { get; init; } + public required string Title { get; init; } + public required Language OriginalLanguage { get; init; } + public required string Source { get; init; } + public required string AuthorName { get; init; } +} diff --git a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs index e9e33be..d95e865 100644 --- a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs +++ b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs @@ -343,6 +343,12 @@ public class NovelUpdateService Novel novel; bool shouldPublishCoverEvent; + // Capture existing chapter IDs to detect new chapters later + var existingChapterIds = existingNovel?.Volumes + .SelectMany(v => v.Chapters) + .Select(c => c.Id) + .ToHashSet() ?? new HashSet(); + if (existingNovel == null) { // CREATE PATH: New novel @@ -384,6 +390,36 @@ public class NovelUpdateService await _dbContext.SaveChangesAsync(); + // Publish novel created event for new novels + if (existingNovel == null) + { + await _eventBus.Publish(new NovelCreatedEvent + { + NovelId = novel.Id, + Title = novel.Name.Texts.First(t => t.Language == novel.RawLanguage).Text, + OriginalLanguage = novel.RawLanguage, + Source = novel.Source.Key, + AuthorName = novel.Author.Name.Texts.First(t => t.Language == novel.RawLanguage).Text + }); + } + + // Publish chapter created events for new chapters + foreach (var volume in novel.Volumes) + { + foreach (var chapter in volume.Chapters.Where(c => !existingChapterIds.Contains(c.Id))) + { + await _eventBus.Publish(new ChapterCreatedEvent + { + ChapterId = chapter.Id, + NovelId = novel.Id, + VolumeId = volume.Id, + VolumeOrder = volume.Order, + ChapterOrder = chapter.Order, + ChapterTitle = chapter.Name.Texts.First(t => t.Language == novel.RawLanguage).Text + }); + } + } + // Publish cover image event if needed if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null) { diff --git a/FictionArchive.Service.UserNovelDataService/Migrations/20260119184741_AddNovelVolumeChapter.Designer.cs b/FictionArchive.Service.UserNovelDataService/Migrations/20260119184741_AddNovelVolumeChapter.Designer.cs new file mode 100644 index 0000000..eb6cd91 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Migrations/20260119184741_AddNovelVolumeChapter.Designer.cs @@ -0,0 +1,198 @@ +// +using System; +using FictionArchive.Service.UserNovelDataService.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.UserNovelDataService.Migrations +{ + [DbContext(typeof(UserNovelDataServiceDbContext))] + [Migration("20260119184741_AddNovelVolumeChapter")] + partial class AddNovelVolumeChapter + { + /// + 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.UserNovelDataService.Models.Database.Bookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChapterId") + .HasColumnType("bigint"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ChapterId") + .IsUnique(); + + b.HasIndex("UserId", "NovelId"); + + b.ToTable("Bookmarks"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("VolumeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("OAuthProviderId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NovelId"); + + b.ToTable("Volumes"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel") + .WithMany("Volumes") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Migrations/20260119184741_AddNovelVolumeChapter.cs b/FictionArchive.Service.UserNovelDataService/Migrations/20260119184741_AddNovelVolumeChapter.cs new file mode 100644 index 0000000..ae3e0c4 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Migrations/20260119184741_AddNovelVolumeChapter.cs @@ -0,0 +1,95 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FictionArchive.Service.UserNovelDataService.Migrations +{ + /// + public partial class AddNovelVolumeChapter : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Novels", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + 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_Novels", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Volumes", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + NovelId = table.Column(type: "bigint", nullable: false), + 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_Volumes", x => x.Id); + table.ForeignKey( + name: "FK_Volumes_Novels_NovelId", + column: x => x.NovelId, + principalTable: "Novels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Chapters", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + VolumeId = table.Column(type: "bigint", nullable: false), + 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_Chapters", x => x.Id); + table.ForeignKey( + name: "FK_Chapters_Volumes_VolumeId", + column: x => x.VolumeId, + principalTable: "Volumes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Chapters_VolumeId", + table: "Chapters", + column: "VolumeId"); + + migrationBuilder.CreateIndex( + name: "IX_Volumes_NovelId", + table: "Volumes", + column: "NovelId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Chapters"); + + migrationBuilder.DropTable( + name: "Volumes"); + + migrationBuilder.DropTable( + name: "Novels"); + } + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs b/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs index 1e50402..507067f 100644 --- a/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs +++ b/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs @@ -59,6 +59,49 @@ namespace FictionArchive.Service.UserNovelDataService.Migrations b.ToTable("Bookmarks"); }); + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("VolumeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Novels"); + }); + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b => { b.Property("Id") @@ -80,6 +123,30 @@ namespace FictionArchive.Service.UserNovelDataService.Migrations b.ToTable("Users"); }); + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NovelId"); + + b.ToTable("Volumes"); + }); + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b => { b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User") @@ -90,6 +157,38 @@ namespace FictionArchive.Service.UserNovelDataService.Migrations b.Navigation("User"); }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel") + .WithMany("Volumes") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b => + { + b.Navigation("Chapters"); + }); #pragma warning restore 612, 618 } } diff --git a/FictionArchive.Service.UserNovelDataService/Models/Database/Chapter.cs b/FictionArchive.Service.UserNovelDataService/Models/Database/Chapter.cs index 1cc7e91..189a7d5 100644 --- a/FictionArchive.Service.UserNovelDataService/Models/Database/Chapter.cs +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/Chapter.cs @@ -4,5 +4,6 @@ namespace FictionArchive.Service.UserNovelDataService.Models.Database; public class Chapter : BaseEntity { - + public uint VolumeId { get; set; } + public virtual Volume Volume { get; set; } = null!; } \ No newline at end of file diff --git a/FictionArchive.Service.UserNovelDataService/Models/Database/Novel.cs b/FictionArchive.Service.UserNovelDataService/Models/Database/Novel.cs index fc979d8..36b0ea2 100644 --- a/FictionArchive.Service.UserNovelDataService/Models/Database/Novel.cs +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/Novel.cs @@ -4,5 +4,5 @@ namespace FictionArchive.Service.UserNovelDataService.Models.Database; public class Novel : BaseEntity { - public virtual ICollection Volumes { get; set; } + public virtual ICollection Volumes { get; set; } = new List(); } \ No newline at end of file diff --git a/FictionArchive.Service.UserNovelDataService/Models/Database/Volume.cs b/FictionArchive.Service.UserNovelDataService/Models/Database/Volume.cs index f5b0293..b371ba2 100644 --- a/FictionArchive.Service.UserNovelDataService/Models/Database/Volume.cs +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/Volume.cs @@ -4,5 +4,7 @@ namespace FictionArchive.Service.UserNovelDataService.Models.Database; public class Volume : BaseEntity { - public virtual ICollection Chapters { get; set; } + public uint NovelId { get; set; } + public virtual Novel Novel { get; set; } = null!; + public virtual ICollection Chapters { get; set; } = new List(); } \ No newline at end of file diff --git a/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/ChapterCreatedEvent.cs b/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/ChapterCreatedEvent.cs new file mode 100644 index 0000000..2591f68 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/ChapterCreatedEvent.cs @@ -0,0 +1,13 @@ +using FictionArchive.Service.Shared.Services.EventBus; + +namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; + +public class ChapterCreatedEvent : IIntegrationEvent +{ + public required uint ChapterId { get; init; } + public required uint NovelId { get; init; } + public required uint VolumeId { get; init; } + public required int VolumeOrder { get; init; } + public required uint ChapterOrder { get; init; } + public required string ChapterTitle { get; init; } +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/NovelCreatedEvent.cs b/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/NovelCreatedEvent.cs new file mode 100644 index 0000000..f55c349 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/NovelCreatedEvent.cs @@ -0,0 +1,13 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.Shared.Services.EventBus; + +namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; + +public class NovelCreatedEvent : IIntegrationEvent +{ + public required uint NovelId { get; init; } + public required string Title { get; init; } + public required Language OriginalLanguage { get; init; } + public required string Source { get; init; } + public required string AuthorName { get; init; } +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/UserInvitedEvent.cs b/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/UserInvitedEvent.cs new file mode 100644 index 0000000..609f029 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/IntegrationEvents/UserInvitedEvent.cs @@ -0,0 +1,15 @@ +using FictionArchive.Service.Shared.Services.EventBus; + +namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; + +public class UserInvitedEvent : IIntegrationEvent +{ + public Guid InvitedUserId { get; set; } + public required string InvitedUsername { get; set; } + public required string InvitedEmail { get; set; } + public required string InvitedOAuthProviderId { get; set; } + + public Guid InviterId { get; set; } + public required string InviterUsername { get; set; } + public required string InviterOAuthProviderId { get; set; } +} diff --git a/FictionArchive.Service.UserNovelDataService/Program.cs b/FictionArchive.Service.UserNovelDataService/Program.cs index 166735f..d14cc0e 100644 --- a/FictionArchive.Service.UserNovelDataService/Program.cs +++ b/FictionArchive.Service.UserNovelDataService/Program.cs @@ -3,7 +3,9 @@ using FictionArchive.Service.Shared; using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.UserNovelDataService.GraphQL; +using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; using FictionArchive.Service.UserNovelDataService.Services; +using FictionArchive.Service.UserNovelDataService.Services.EventHandlers; namespace FictionArchive.Service.UserNovelDataService; @@ -27,7 +29,10 @@ public class Program builder.Services.AddRabbitMQ(opt => { builder.Configuration.GetSection("RabbitMQ").Bind(opt); - }); + }) + .Subscribe() + .Subscribe() + .Subscribe(); } #endregion diff --git a/FictionArchive.Service.UserNovelDataService/Scripts/00_README.md b/FictionArchive.Service.UserNovelDataService/Scripts/00_README.md new file mode 100644 index 0000000..1db2939 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Scripts/00_README.md @@ -0,0 +1,93 @@ +# UserNovelDataService Backfill Scripts + +SQL scripts for backfilling data from UserService and NovelService into UserNovelDataService. + +## Prerequisites + +1. **Run EF migrations** on the UserNovelDataService database to ensure all tables exist: + ```bash + dotnet ef database update --project FictionArchive.Service.UserNovelDataService + ``` + + This will apply the `AddNovelVolumeChapter` migration which creates: + - `Novels` table (Id, CreatedTime, LastUpdatedTime) + - `Volumes` table (Id, NovelId FK, CreatedTime, LastUpdatedTime) + - `Chapters` table (Id, VolumeId FK, CreatedTime, LastUpdatedTime) + +## Execution Order + +Run scripts in numeric order: + +### Extraction (run against source databases) +1. `01_extract_users_from_userservice.sql` - Run against **UserService** DB +2. `02_extract_novels_from_novelservice.sql` - Run against **NovelService** DB +3. `03_extract_volumes_from_novelservice.sql` - Run against **NovelService** DB +4. `04_extract_chapters_from_novelservice.sql` - Run against **NovelService** DB + +### Insertion (run against UserNovelDataService database) +5. `05_insert_users_to_usernoveldataservice.sql` +6. `06_insert_novels_to_usernoveldataservice.sql` +7. `07_insert_volumes_to_usernoveldataservice.sql` +8. `08_insert_chapters_to_usernoveldataservice.sql` + +## Methods + +Each script provides three options: + +1. **SELECT for review** - Review data before export +2. **Generate INSERT statements** - Creates individual INSERT statements (good for small datasets) +3. **CSV export/import** - Use PostgreSQL `\copy` for bulk operations (recommended for large datasets) + +## Example Workflow + +### Using CSV Export/Import (Recommended) + +```bash +# 1. Export from source databases +psql -h localhost -U postgres -d userservice -c "\copy (SELECT \"Id\", \"OAuthProviderId\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Users\" WHERE \"Disabled\" = false) TO '/tmp/users_export.csv' WITH CSV HEADER" + +psql -h localhost -U postgres -d novelservice -c "\copy (SELECT \"Id\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Novels\") TO '/tmp/novels_export.csv' WITH CSV HEADER" + +psql -h localhost -U postgres -d novelservice -c "\copy (SELECT \"Id\", \"NovelId\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Volume\" ORDER BY \"NovelId\", \"Id\") TO '/tmp/volumes_export.csv' WITH CSV HEADER" + +psql -h localhost -U postgres -d novelservice -c "\copy (SELECT \"Id\", \"VolumeId\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Chapter\" ORDER BY \"VolumeId\", \"Id\") TO '/tmp/chapters_export.csv' WITH CSV HEADER" + +# 2. Import into UserNovelDataService (order matters due to FK constraints!) +psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Users\" (\"Id\", \"OAuthProviderId\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/users_export.csv' WITH CSV HEADER" + +psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Novels\" (\"Id\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/novels_export.csv' WITH CSV HEADER" + +psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Volumes\" (\"Id\", \"NovelId\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/volumes_export.csv' WITH CSV HEADER" + +psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Chapters\" (\"Id\", \"VolumeId\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/chapters_export.csv' WITH CSV HEADER" +``` + +**Important**: Insert order matters due to foreign key constraints: +1. Users (no dependencies) +2. Novels (no dependencies) +3. Volumes (depends on Novels) +4. Chapters (depends on Volumes) + +### Using dblink (Cross-database queries) + +If both databases are on the same PostgreSQL server, you can use `dblink` extension for direct cross-database inserts. See the commented examples in each insert script. + +## Verification + +After running the backfill, verify counts match: + +```sql +-- Run on UserService DB +SELECT COUNT(*) as user_count FROM "Users" WHERE "Disabled" = false; + +-- Run on NovelService DB +SELECT COUNT(*) as novel_count FROM "Novels"; +SELECT COUNT(*) as volume_count FROM "Volume"; +SELECT COUNT(*) as chapter_count FROM "Chapter"; + +-- Run on UserNovelDataService DB +SELECT COUNT(*) as user_count FROM "Users"; +SELECT COUNT(*) as novel_count FROM "Novels"; +SELECT COUNT(*) as volume_count FROM "Volumes"; +SELECT COUNT(*) as chapter_count FROM "Chapters"; +``` diff --git a/FictionArchive.Service.UserNovelDataService/Scripts/01_extract_users_from_userservice.sql b/FictionArchive.Service.UserNovelDataService/Scripts/01_extract_users_from_userservice.sql new file mode 100644 index 0000000..e818089 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Scripts/01_extract_users_from_userservice.sql @@ -0,0 +1,28 @@ +-- Extract Users from UserService database +-- Run this against: UserService PostgreSQL database +-- Output: CSV or use COPY TO for bulk export + +-- Option 1: Simple SELECT for review/testing +SELECT + "Id", + "OAuthProviderId", + "CreatedTime", + "LastUpdatedTime" +FROM "Users" +WHERE "Disabled" = false +ORDER BY "CreatedTime"; + +-- Option 2: Generate INSERT statements (useful for small datasets) +SELECT format( + 'INSERT INTO "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime") VALUES (%L, %L, %L, %L) ON CONFLICT ("Id") DO NOTHING;', + "Id", + "OAuthProviderId", + "CreatedTime", + "LastUpdatedTime" +) +FROM "Users" +WHERE "Disabled" = false +ORDER BY "CreatedTime"; + +-- Option 3: Export to CSV (run from psql) +-- \copy (SELECT "Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime" FROM "Users" WHERE "Disabled" = false ORDER BY "CreatedTime") TO '/tmp/users_export.csv' WITH CSV HEADER; diff --git a/FictionArchive.Service.UserNovelDataService/Scripts/02_extract_novels_from_novelservice.sql b/FictionArchive.Service.UserNovelDataService/Scripts/02_extract_novels_from_novelservice.sql new file mode 100644 index 0000000..9fa1b0e --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Scripts/02_extract_novels_from_novelservice.sql @@ -0,0 +1,24 @@ +-- Extract Novels from NovelService database +-- Run this against: NovelService PostgreSQL database +-- Output: CSV or use COPY TO for bulk export + +-- Option 1: Simple SELECT for review/testing +SELECT + "Id", + "CreatedTime", + "LastUpdatedTime" +FROM "Novels" +ORDER BY "Id"; + +-- Option 2: Generate INSERT statements +SELECT format( + 'INSERT INTO "Novels" ("Id", "CreatedTime", "LastUpdatedTime") VALUES (%s, %L, %L) ON CONFLICT ("Id") DO NOTHING;', + "Id", + "CreatedTime", + "LastUpdatedTime" +) +FROM "Novels" +ORDER BY "Id"; + +-- Option 3: Export to CSV (run from psql) +-- \copy (SELECT "Id", "CreatedTime", "LastUpdatedTime" FROM "Novels" ORDER BY "Id") TO '/tmp/novels_export.csv' WITH CSV HEADER; diff --git a/FictionArchive.Service.UserNovelDataService/Scripts/03_extract_volumes_from_novelservice.sql b/FictionArchive.Service.UserNovelDataService/Scripts/03_extract_volumes_from_novelservice.sql new file mode 100644 index 0000000..b64f25b --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Scripts/03_extract_volumes_from_novelservice.sql @@ -0,0 +1,26 @@ +-- Extract Volumes from NovelService database +-- Run this against: NovelService PostgreSQL database +-- Output: CSV or use COPY TO for bulk export + +-- Option 1: Simple SELECT for review/testing +SELECT + "Id", + "NovelId", + "CreatedTime", + "LastUpdatedTime" +FROM "Volume" +ORDER BY "NovelId", "Id"; + +-- Option 2: Generate INSERT statements +SELECT format( + 'INSERT INTO "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime") VALUES (%s, %s, %L, %L) ON CONFLICT ("Id") DO NOTHING;', + "Id", + "NovelId", + "CreatedTime", + "LastUpdatedTime" +) +FROM "Volume" +ORDER BY "NovelId", "Id"; + +-- Option 3: Export to CSV (run from psql) +-- \copy (SELECT "Id", "NovelId", "CreatedTime", "LastUpdatedTime" FROM "Volume" ORDER BY "NovelId", "Id") TO '/tmp/volumes_export.csv' WITH CSV HEADER; diff --git a/FictionArchive.Service.UserNovelDataService/Scripts/04_extract_chapters_from_novelservice.sql b/FictionArchive.Service.UserNovelDataService/Scripts/04_extract_chapters_from_novelservice.sql new file mode 100644 index 0000000..30886b7 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Scripts/04_extract_chapters_from_novelservice.sql @@ -0,0 +1,26 @@ +-- Extract Chapters from NovelService database +-- Run this against: NovelService PostgreSQL database +-- Output: CSV or use COPY TO for bulk export + +-- Option 1: Simple SELECT for review/testing +SELECT + "Id", + "VolumeId", + "CreatedTime", + "LastUpdatedTime" +FROM "Chapter" +ORDER BY "VolumeId", "Id"; + +-- Option 2: Generate INSERT statements +SELECT format( + 'INSERT INTO "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime") VALUES (%s, %s, %L, %L) ON CONFLICT ("Id") DO NOTHING;', + "Id", + "VolumeId", + "CreatedTime", + "LastUpdatedTime" +) +FROM "Chapter" +ORDER BY "VolumeId", "Id"; + +-- Option 3: Export to CSV (run from psql) +-- \copy (SELECT "Id", "VolumeId", "CreatedTime", "LastUpdatedTime" FROM "Chapter" ORDER BY "VolumeId", "Id") TO '/tmp/chapters_export.csv' WITH CSV HEADER; diff --git a/FictionArchive.Service.UserNovelDataService/Scripts/05_insert_users_to_usernoveldataservice.sql b/FictionArchive.Service.UserNovelDataService/Scripts/05_insert_users_to_usernoveldataservice.sql new file mode 100644 index 0000000..fa5ae5c --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Scripts/05_insert_users_to_usernoveldataservice.sql @@ -0,0 +1,32 @@ +-- Insert Users into UserNovelDataService database +-- Run this against: UserNovelDataService PostgreSQL database +-- +-- PREREQUISITE: You must have extracted users from UserService first +-- using 01_extract_users_from_userservice.sql + +-- Option 1: If you have a CSV file from export +-- \copy "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime") FROM '/tmp/users_export.csv' WITH CSV HEADER; + +-- Option 2: Direct cross-database insert using dblink +-- First, install dblink extension if not already done: +-- CREATE EXTENSION IF NOT EXISTS dblink; + +-- Example using dblink (adjust connection string): +/* +INSERT INTO "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime") +SELECT + "Id"::uuid, + "OAuthProviderId", + "CreatedTime"::timestamp with time zone, + "LastUpdatedTime"::timestamp with time zone +FROM dblink( + 'host=localhost port=5432 dbname=userservice user=postgres password=yourpassword', + 'SELECT "Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime" FROM "Users" WHERE "Disabled" = false' +) AS t("Id" uuid, "OAuthProviderId" text, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone) +ON CONFLICT ("Id") DO UPDATE SET + "OAuthProviderId" = EXCLUDED."OAuthProviderId", + "LastUpdatedTime" = EXCLUDED."LastUpdatedTime"; +*/ + +-- Option 3: Paste generated INSERT statements from extraction script here +-- INSERT INTO "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING; diff --git a/FictionArchive.Service.UserNovelDataService/Scripts/06_insert_novels_to_usernoveldataservice.sql b/FictionArchive.Service.UserNovelDataService/Scripts/06_insert_novels_to_usernoveldataservice.sql new file mode 100644 index 0000000..bf9b307 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Scripts/06_insert_novels_to_usernoveldataservice.sql @@ -0,0 +1,31 @@ +-- Insert Novels into UserNovelDataService database +-- Run this against: UserNovelDataService PostgreSQL database +-- +-- PREREQUISITE: +-- 1. Ensure the Novels table exists (run EF migrations first if needed) +-- 2. Extract novels from NovelService using 02_extract_novels_from_novelservice.sql + +-- Option 1: If you have a CSV file from export +-- \copy "Novels" ("Id", "CreatedTime", "LastUpdatedTime") FROM '/tmp/novels_export.csv' WITH CSV HEADER; + +-- Option 2: Direct cross-database insert using dblink +-- First, install dblink extension if not already done: +-- CREATE EXTENSION IF NOT EXISTS dblink; + +-- Example using dblink (adjust connection string): +/* +INSERT INTO "Novels" ("Id", "CreatedTime", "LastUpdatedTime") +SELECT + "Id"::bigint, + "CreatedTime"::timestamp with time zone, + "LastUpdatedTime"::timestamp with time zone +FROM dblink( + 'host=localhost port=5432 dbname=novelservice user=postgres password=yourpassword', + 'SELECT "Id", "CreatedTime", "LastUpdatedTime" FROM "Novels"' +) AS t("Id" bigint, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone) +ON CONFLICT ("Id") DO UPDATE SET + "LastUpdatedTime" = EXCLUDED."LastUpdatedTime"; +*/ + +-- Option 3: Paste generated INSERT statements from extraction script here +-- INSERT INTO "Novels" ("Id", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING; diff --git a/FictionArchive.Service.UserNovelDataService/Scripts/07_insert_volumes_to_usernoveldataservice.sql b/FictionArchive.Service.UserNovelDataService/Scripts/07_insert_volumes_to_usernoveldataservice.sql new file mode 100644 index 0000000..36af9f3 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Scripts/07_insert_volumes_to_usernoveldataservice.sql @@ -0,0 +1,34 @@ +-- Insert Volumes into UserNovelDataService database +-- Run this against: UserNovelDataService PostgreSQL database +-- +-- PREREQUISITE: +-- 1. Ensure the Volumes table exists (run EF migrations first if needed) +-- 2. Novels must be inserted first (FK constraint) +-- 3. Extract volumes from NovelService using 03_extract_volumes_from_novelservice.sql + +-- Option 1: If you have a CSV file from export +-- \copy "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime") FROM '/tmp/volumes_export.csv' WITH CSV HEADER; + +-- Option 2: Direct cross-database insert using dblink +-- First, install dblink extension if not already done: +-- CREATE EXTENSION IF NOT EXISTS dblink; + +-- Example using dblink (adjust connection string): +/* +INSERT INTO "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime") +SELECT + "Id"::bigint, + "NovelId"::bigint, + "CreatedTime"::timestamp with time zone, + "LastUpdatedTime"::timestamp with time zone +FROM dblink( + 'host=localhost port=5432 dbname=novelservice user=postgres password=yourpassword', + 'SELECT "Id", "NovelId", "CreatedTime", "LastUpdatedTime" FROM "Volume"' +) AS t("Id" bigint, "NovelId" bigint, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone) +ON CONFLICT ("Id") DO UPDATE SET + "NovelId" = EXCLUDED."NovelId", + "LastUpdatedTime" = EXCLUDED."LastUpdatedTime"; +*/ + +-- Option 3: Paste generated INSERT statements from extraction script here +-- INSERT INTO "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING; diff --git a/FictionArchive.Service.UserNovelDataService/Scripts/08_insert_chapters_to_usernoveldataservice.sql b/FictionArchive.Service.UserNovelDataService/Scripts/08_insert_chapters_to_usernoveldataservice.sql new file mode 100644 index 0000000..5f03100 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Scripts/08_insert_chapters_to_usernoveldataservice.sql @@ -0,0 +1,34 @@ +-- Insert Chapters into UserNovelDataService database +-- Run this against: UserNovelDataService PostgreSQL database +-- +-- PREREQUISITE: +-- 1. Ensure the Chapters table exists (run EF migrations first if needed) +-- 2. Volumes must be inserted first (FK constraint) +-- 3. Extract chapters from NovelService using 04_extract_chapters_from_novelservice.sql + +-- Option 1: If you have a CSV file from export +-- \copy "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime") FROM '/tmp/chapters_export.csv' WITH CSV HEADER; + +-- Option 2: Direct cross-database insert using dblink +-- First, install dblink extension if not already done: +-- CREATE EXTENSION IF NOT EXISTS dblink; + +-- Example using dblink (adjust connection string): +/* +INSERT INTO "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime") +SELECT + "Id"::bigint, + "VolumeId"::bigint, + "CreatedTime"::timestamp with time zone, + "LastUpdatedTime"::timestamp with time zone +FROM dblink( + 'host=localhost port=5432 dbname=novelservice user=postgres password=yourpassword', + 'SELECT "Id", "VolumeId", "CreatedTime", "LastUpdatedTime" FROM "Chapter"' +) AS t("Id" bigint, "VolumeId" bigint, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone) +ON CONFLICT ("Id") DO UPDATE SET + "VolumeId" = EXCLUDED."VolumeId", + "LastUpdatedTime" = EXCLUDED."LastUpdatedTime"; +*/ + +-- Option 3: Paste generated INSERT statements from extraction script here +-- INSERT INTO "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING; diff --git a/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/ChapterCreatedEventHandler.cs b/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/ChapterCreatedEventHandler.cs new file mode 100644 index 0000000..74f46f7 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/ChapterCreatedEventHandler.cs @@ -0,0 +1,53 @@ +using FictionArchive.Service.Shared.Services.EventBus; +using FictionArchive.Service.UserNovelDataService.Models.Database; +using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; +using Microsoft.EntityFrameworkCore; + +namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers; + +public class ChapterCreatedEventHandler : IIntegrationEventHandler +{ + private readonly UserNovelDataServiceDbContext _dbContext; + private readonly ILogger _logger; + + public ChapterCreatedEventHandler( + UserNovelDataServiceDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + public async Task Handle(ChapterCreatedEvent @event) + { + // Ensure novel exists + var novelExists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId); + if (!novelExists) + { + var novel = new Novel { Id = @event.NovelId }; + _dbContext.Novels.Add(novel); + } + + // Ensure volume exists + var volumeExists = await _dbContext.Volumes.AnyAsync(v => v.Id == @event.VolumeId); + if (!volumeExists) + { + var volume = new Volume { Id = @event.VolumeId }; + _dbContext.Volumes.Add(volume); + } + + // Create chapter if not exists + var chapterExists = await _dbContext.Chapters.AnyAsync(c => c.Id == @event.ChapterId); + if (chapterExists) + { + _logger.LogDebug("Chapter {ChapterId} already exists, skipping", @event.ChapterId); + return; + } + + var chapter = new Chapter { Id = @event.ChapterId }; + _dbContext.Chapters.Add(chapter); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Created chapter stub for {ChapterId} in novel {NovelId}", @event.ChapterId, @event.NovelId); + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/NovelCreatedEventHandler.cs b/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/NovelCreatedEventHandler.cs new file mode 100644 index 0000000..1e47531 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/NovelCreatedEventHandler.cs @@ -0,0 +1,36 @@ +using FictionArchive.Service.Shared.Services.EventBus; +using FictionArchive.Service.UserNovelDataService.Models.Database; +using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; +using Microsoft.EntityFrameworkCore; + +namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers; + +public class NovelCreatedEventHandler : IIntegrationEventHandler +{ + private readonly UserNovelDataServiceDbContext _dbContext; + private readonly ILogger _logger; + + public NovelCreatedEventHandler( + UserNovelDataServiceDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + public async Task Handle(NovelCreatedEvent @event) + { + var exists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId); + if (exists) + { + _logger.LogDebug("Novel {NovelId} already exists, skipping", @event.NovelId); + return; + } + + var novel = new Novel { Id = @event.NovelId }; + _dbContext.Novels.Add(novel); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Created novel stub for {NovelId}", @event.NovelId); + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/UserInvitedEventHandler.cs b/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/UserInvitedEventHandler.cs new file mode 100644 index 0000000..a48a2c8 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Services/EventHandlers/UserInvitedEventHandler.cs @@ -0,0 +1,40 @@ +using FictionArchive.Service.Shared.Services.EventBus; +using FictionArchive.Service.UserNovelDataService.Models.Database; +using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents; +using Microsoft.EntityFrameworkCore; + +namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers; + +public class UserInvitedEventHandler : IIntegrationEventHandler +{ + private readonly UserNovelDataServiceDbContext _dbContext; + private readonly ILogger _logger; + + public UserInvitedEventHandler( + UserNovelDataServiceDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + public async Task Handle(UserInvitedEvent @event) + { + var exists = await _dbContext.Users.AnyAsync(u => u.Id == @event.InvitedUserId); + if (exists) + { + _logger.LogDebug("User {UserId} already exists, skipping", @event.InvitedUserId); + return; + } + + var user = new User + { + Id = @event.InvitedUserId, + OAuthProviderId = @event.InvitedOAuthProviderId + }; + _dbContext.Users.Add(user); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Created user stub for {UserId}", @event.InvitedUserId); + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs b/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs index 38eb08c..289eb48 100644 --- a/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs +++ b/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs @@ -8,6 +8,9 @@ public class UserNovelDataServiceDbContext : FictionArchiveDbContext { public DbSet Users { get; set; } public DbSet Bookmarks { get; set; } + public DbSet Novels { get; set; } + public DbSet Volumes { get; set; } + public DbSet Chapters { get; set; } public UserNovelDataServiceDbContext(DbContextOptions options, ILogger logger) : base(options, logger) { From c97654631b6b23581c01b9f4db48ee92dcb7ea40 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 19 Jan 2026 15:40:21 -0500 Subject: [PATCH 5/8] [FA-27] Still need to test events --- .../components/ChapterBookmarkButton.svelte | 181 ++++++++++++++++++ .../src/lib/components/NovelDetailPage.svelte | 99 +++++++--- 2 files changed, 256 insertions(+), 24 deletions(-) create mode 100644 fictionarchive-web-astro/src/lib/components/ChapterBookmarkButton.svelte diff --git a/fictionarchive-web-astro/src/lib/components/ChapterBookmarkButton.svelte b/fictionarchive-web-astro/src/lib/components/ChapterBookmarkButton.svelte new file mode 100644 index 0000000..a932a1d --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/ChapterBookmarkButton.svelte @@ -0,0 +1,181 @@ + + + + +
+ + + {#snippet child({ props })} + + {/snippet} + + +
+
+

+ {isBookmarked ? 'Edit bookmark' : 'Bookmark this chapter'} +

+

+ {isBookmarked ? 'Update your note or remove the bookmark.' : 'Add an optional note to remember why you bookmarked this.'} +

+
+