diff --git a/src/NexusReader.Data/Migrations/20260510171941_AddEbookLastChapterIndex.Designer.cs b/src/NexusReader.Data/Migrations/20260510171941_AddEbookLastChapterIndex.Designer.cs
new file mode 100644
index 0000000..10bb2d0
--- /dev/null
+++ b/src/NexusReader.Data/Migrations/20260510171941_AddEbookLastChapterIndex.Designer.cs
@@ -0,0 +1,700 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NexusReader.Data.Persistence;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Pgvector;
+
+#nullable disable
+
+namespace NexusReader.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260510171941_AddEbookLastChapterIndex")]
+ partial class AddEbookLastChapterIndex
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Authors");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AddedDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AuthorId")
+ .HasColumnType("integer");
+
+ b.Property("CoverUrl")
+ .HasColumnType("text");
+
+ b.Property("FilePath")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("LastChapter")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("LastChapterIndex")
+ .HasColumnType("integer");
+
+ b.Property("LastReadDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Progress")
+ .HasColumnType("double precision");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorId");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Ebooks");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MetadataJson")
+ .HasColumnType("text");
+
+ b.Property("SourceId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.Property("Vector")
+ .HasColumnType("vector(768)");
+
+ b.Property("Version")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SourceId");
+
+ b.HasIndex("TenantId");
+
+ b.ToTable("KnowledgeUnits");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("RelationType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("SourceUnitId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TargetUnitId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SourceUnitId");
+
+ b.HasIndex("TargetUnitId");
+
+ b.ToTable("KnowledgeUnitLinks");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AITokenLimit")
+ .HasColumnType("integer");
+
+ b.Property("AITokensUsed")
+ .HasColumnType("integer");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("DisplayName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("LastAiActionDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastReadAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastReadPageId")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text");
+
+ b.Property("SubscriptionPlanId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.HasIndex("SubscriptionPlanId");
+
+ b.HasIndex("TenantId");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CompletedDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Score")
+ .HasColumnType("integer");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Topic")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TotalQuestions")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("QuizResults");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
+ {
+ b.Property("ContentHash")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("JsonData")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ModelId")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("OriginalText")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PromptVersion")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Vector")
+ .HasColumnType("vector(1536)");
+
+ b.HasKey("ContentHash");
+
+ b.HasIndex("ContentHash")
+ .IsUnique();
+
+ b.HasIndex("TenantId");
+
+ b.ToTable("SemanticKnowledgeCache");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AITokenLimit")
+ .HasColumnType("integer");
+
+ b.Property("IsUnlimitedTokens")
+ .HasColumnType("boolean");
+
+ b.Property("MonthlyPrice")
+ .HasColumnType("numeric");
+
+ b.Property("PlanName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("StripeProductId")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlanName")
+ .IsUnique();
+
+ b.ToTable("SubscriptionPlans");
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ AITokenLimit = 5000,
+ IsUnlimitedTokens = false,
+ MonthlyPrice = 0m,
+ PlanName = "Free",
+ StripeProductId = "prod_Free789"
+ },
+ new
+ {
+ Id = 2,
+ AITokenLimit = 10000,
+ IsUnlimitedTokens = false,
+ MonthlyPrice = 9.99m,
+ PlanName = "Basic",
+ StripeProductId = "prod_basic_placeholder"
+ },
+ new
+ {
+ Id = 3,
+ AITokenLimit = 50000,
+ IsUnlimitedTokens = false,
+ MonthlyPrice = 19.99m,
+ PlanName = "Pro",
+ StripeProductId = "prod_pro_placeholder"
+ },
+ new
+ {
+ Id = 4,
+ AITokenLimit = 1000000000,
+ IsUnlimitedTokens = true,
+ MonthlyPrice = 99.99m,
+ PlanName = "Enterprise",
+ StripeProductId = "prod_enterprise_placeholder"
+ });
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
+ {
+ b.HasOne("NexusReader.Domain.Entities.Author", "Author")
+ .WithMany("Ebooks")
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
+ .WithMany("Ebooks")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Author");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
+ {
+ b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
+ .WithMany("OutgoingLinks")
+ .HasForeignKey("SourceUnitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
+ .WithMany("IncomingLinks")
+ .HasForeignKey("TargetUnitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("SourceUnit");
+
+ b.Navigation("TargetUnit");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
+ {
+ b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
+ .WithMany()
+ .HasForeignKey("SubscriptionPlanId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("SubscriptionPlan");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
+ {
+ b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
+ .WithMany("QuizResults")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
+ {
+ b.Navigation("Ebooks");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
+ {
+ b.Navigation("IncomingLinks");
+
+ b.Navigation("OutgoingLinks");
+ });
+
+ modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
+ {
+ b.Navigation("Ebooks");
+
+ b.Navigation("QuizResults");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/NexusReader.Data/Migrations/20260510171941_AddEbookLastChapterIndex.cs b/src/NexusReader.Data/Migrations/20260510171941_AddEbookLastChapterIndex.cs
new file mode 100644
index 0000000..0302948
--- /dev/null
+++ b/src/NexusReader.Data/Migrations/20260510171941_AddEbookLastChapterIndex.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace NexusReader.Data.Migrations
+{
+ ///
+ public partial class AddEbookLastChapterIndex : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "LastChapterIndex",
+ table: "Ebooks",
+ type: "integer",
+ nullable: false,
+ defaultValue: 0);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "LastChapterIndex",
+ table: "Ebooks");
+ }
+ }
+}
diff --git a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs
index 6fbdc71..3b0cb08 100644
--- a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs
@@ -197,6 +197,9 @@ namespace NexusReader.Data.Migrations
.HasMaxLength(255)
.HasColumnType("character varying(255)");
+ b.Property("LastChapterIndex")
+ .HasColumnType("integer");
+
b.Property("LastReadDate")
.HasColumnType("timestamp with time zone");