diff --git a/.gitea/workflows/build-gateway.yml b/.gitea/workflows/build-gateway.yml index 868f992..4853865 100644 --- a/.gitea/workflows/build-gateway.yml +++ b/.gitea/workflows/build-gateway.yml @@ -28,6 +28,9 @@ jobs: - name: user-service project: FictionArchive.Service.UserService subgraph: User + - name: usernoveldata-service + project: FictionArchive.Service.UserNovelDataService + subgraph: UserNovelData steps: - name: Checkout uses: actions/checkout@v4 @@ -110,6 +113,12 @@ jobs: name: user-service-subgraph path: subgraphs/user + - name: Download UserNovelData Service subgraph + uses: christopherhx/gitea-download-artifact@v4 + with: + name: usernoveldata-service-subgraph + path: subgraphs/usernoveldata + - name: Configure subgraph URLs for Docker run: | for fsp in subgraphs/*/*.fsp; do diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index d814c2d..44d4d07 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -27,6 +27,8 @@ jobs: dockerfile: FictionArchive.Service.SchedulerService/Dockerfile - name: authentication-service dockerfile: FictionArchive.Service.AuthenticationService/Dockerfile + - name: usernoveldata-service + dockerfile: FictionArchive.Service.UserNovelDataService/Dockerfile steps: - name: Checkout uses: actions/checkout@v4 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 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/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..e486c8c --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + 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 new file mode 100644 index 0000000..09a46e1 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs @@ -0,0 +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 +{ + [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 new file mode 100644 index 0000000..0b6e297 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs @@ -0,0 +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 +{ + [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/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 new file mode 100644 index 0000000..507067f --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs @@ -0,0 +1,195 @@ +// +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.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/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..189a7d5 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/Chapter.cs @@ -0,0 +1,9 @@ +using FictionArchive.Service.Shared.Models; + +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 new file mode 100644 index 0000000..36b0ea2 --- /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; } = new List(); +} \ 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..b371ba2 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/Volume.cs @@ -0,0 +1,10 @@ +using FictionArchive.Service.Shared.Models; + +namespace FictionArchive.Service.UserNovelDataService.Models.Database; + +public class Volume : BaseEntity +{ + 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 new file mode 100644 index 0000000..d14cc0e --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Program.cs @@ -0,0 +1,80 @@ +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.Models.IntegrationEvents; +using FictionArchive.Service.UserNovelDataService.Services; +using FictionArchive.Service.UserNovelDataService.Services.EventHandlers; + +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); + }) + .Subscribe() + .Subscribe() + .Subscribe(); + } + + #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/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 new file mode 100644 index 0000000..289eb48 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs @@ -0,0 +1,38 @@ +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 DbSet Novels { get; set; } + public DbSet Volumes { get; set; } + public DbSet Chapters { get; set; } + + public UserNovelDataServiceDbContext(DbContextOptions options, ILogger logger) : base(options, logger) + { + } + + 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.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..425535a --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/appsettings.json @@ -0,0 +1,26 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=FictionArchive_UserNovelDataService;Username=postgres;password=postgres" + }, + "RabbitMQ": { + "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.Service.UserService.Tests/UserManagementServiceTests.cs b/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs index c7c804d..9050d79 100644 --- a/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs +++ b/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs @@ -213,10 +213,10 @@ public class UserManagementServiceTests dbContext.Users.Add(inviter); await dbContext.SaveChangesAsync(); - var authentikUid = "authentik-uid-789"; + var authentikPk = 456; var authClient = Substitute.For(); authClient.CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new AuthentikUserResponse { Pk = 456, Uid = authentikUid }); + .Returns(new AuthentikUserResponse { Pk = authentikPk, Uid = "authentik-uid-789" }); authClient.SendRecoveryEmailAsync(Arg.Any()).Returns(true); var service = CreateService(dbContext, authClient); @@ -228,7 +228,7 @@ public class UserManagementServiceTests result.Should().NotBeNull(); result!.Username.Should().Be("newusername"); result.Email.Should().Be("newuser@test.com"); - result.OAuthProviderId.Should().Be(authentikUid); + result.OAuthProviderId.Should().Be(authentikPk.ToString()); result.InviterId.Should().Be(inviter.Id); result.AvailableInvites.Should().Be(0); result.Disabled.Should().BeFalse(); diff --git a/FictionArchive.Service.UserService/Services/UserManagementService.cs b/FictionArchive.Service.UserService/Services/UserManagementService.cs index 9e7a6cb..6a5a496 100644 --- a/FictionArchive.Service.UserService/Services/UserManagementService.cs +++ b/FictionArchive.Service.UserService/Services/UserManagementService.cs @@ -86,7 +86,7 @@ public class UserManagementService { Username = username, Email = email, - OAuthProviderId = authentikUser.Uid, + OAuthProviderId = authentikUser.Pk.ToString(), Disabled = false, AvailableInvites = 0, InviterId = inviter.Id 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 diff --git a/docker-compose.yml b/docker-compose.yml index 9dcf61f..2078e52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,8 @@ services: postgres: image: postgres:16-alpine networks: - - fictionarchive + fictionarchive: + ipv4_address: 172.20.0.10 environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} @@ -23,10 +24,12 @@ services: rabbitmq: image: rabbitmq:3-management-alpine networks: - - fictionarchive + fictionarchive: + ipv4_address: 172.20.0.11 environment: RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest} + RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: -rabbit max_message_size 536870912 volumes: - /srv/docker_volumes/fictionarchive/rabbitmq:/var/lib/rabbitmq healthcheck: @@ -36,10 +39,14 @@ services: retries: 5 restart: unless-stopped + # =========================================== + # VPN Container + # =========================================== vpn: - image: dperson/openvpn-client # or gluetun, wireguard, etc. + image: dperson/openvpn-client networks: fictionarchive: + ipv4_address: 172.20.0.20 aliases: - novel-service cap_add: @@ -48,6 +55,19 @@ services: - /dev/net/tun volumes: - /srv/docker_volumes/korean_vpn:/vpn + dns: + - 192.168.3.1 + environment: + - DNS=1.1.1.1,8.8.8.8 + extra_hosts: + - "postgres:172.20.0.10" + - "rabbitmq:172.20.0.11" + healthcheck: + test: ["CMD", "ping", "-c", "1", "-W", "5", "1.1.1.1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s restart: unless-stopped # =========================================== @@ -67,7 +87,7 @@ services: rabbitmq: condition: service_healthy vpn: - condition: service_started + condition: service_healthy network_mode: "service:vpn" restart: unless-stopped @@ -102,6 +122,20 @@ services: condition: service_healthy restart: unless-stopped + usernoveldata-service: + image: git.orfl.xyz/conco/fictionarchive-usernoveldata-service:latest + networks: + - fictionarchive + environment: + ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_UserNovelDataService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres} + RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + restart: unless-stopped + file-service: image: git.orfl.xyz/conco/fictionarchive-file-service:latest networks: @@ -144,6 +178,7 @@ services: - scheduler-service - file-service - user-service + - usernoveldata-service restart: unless-stopped # =========================================== @@ -165,3 +200,7 @@ networks: web: external: yes fictionarchive: + ipam: + driver: default + config: + - subnet: 172.20.0.0/24 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.'} +

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