From b456194ea169e401b56338cb1e4d2e51a219fd02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sun, 10 May 2026 18:15:29 +0200 Subject: [PATCH] feat: implement real-time reading progress and refactor profile to CQRS --- .../nexus-architecture-standards/SKILL.md | 7 + .../Sync/UpdateReadingProgressCommand.cs | 8 +- .../Queries/Reader/ViewModels.cs | 2 +- .../Queries/User/GetUserProfileQuery.cs | 7 + .../User/GetUserProfileQueryHandler.cs | 60 ++ ...20260510151022_NormalizeAuthor.Designer.cs | 690 +++++++++++++++++ .../20260510151022_NormalizeAuthor.cs | 79 ++ ...155_AddEbookProgressAndChapter.Designer.cs | 697 ++++++++++++++++++ ...260510161155_AddEbookProgressAndChapter.cs | 40 + .../Migrations/AppDbContextModelSnapshot.cs | 58 +- .../Persistence/DbInitializer.cs | 27 + src/NexusReader.Domain/Entities/Ebook.cs | 5 + .../UpdateReadingProgressCommandHandler.cs | 9 + .../RealTime/SyncHub.cs | 4 +- .../Services/EpubService.cs | 18 +- .../Components/Organisms/ReaderCanvas.razor | 14 +- .../Services/ISyncService.cs | 2 +- .../Services/SyncService.cs | 5 +- src/NexusReader.Web.New/Program.cs | 45 +- .../Services/ServerIdentityService.cs | 57 +- 20 files changed, 1738 insertions(+), 96 deletions(-) create mode 100644 src/NexusReader.Application/Queries/User/GetUserProfileQuery.cs create mode 100644 src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs create mode 100644 src/NexusReader.Data/Migrations/20260510151022_NormalizeAuthor.Designer.cs create mode 100644 src/NexusReader.Data/Migrations/20260510151022_NormalizeAuthor.cs create mode 100644 src/NexusReader.Data/Migrations/20260510161155_AddEbookProgressAndChapter.Designer.cs create mode 100644 src/NexusReader.Data/Migrations/20260510161155_AddEbookProgressAndChapter.cs diff --git a/.agent/skills/nexus-architecture-standards/SKILL.md b/.agent/skills/nexus-architecture-standards/SKILL.md index 1115a8e..b331f76 100644 --- a/.agent/skills/nexus-architecture-standards/SKILL.md +++ b/.agent/skills/nexus-architecture-standards/SKILL.md @@ -36,6 +36,13 @@ This skill defines the architectural guardrails for the NexusReader project to e +- Event handlers MUST use `Func` or async-compatible patterns. +- UI components MUST await all service calls and use `InvokeAsync(StateHasChanged)` for state updates within async contexts. +### 6. Database Schema Changes +- Every change to a Domain entity or DbContext MUST be followed by the generation of a new EF Core migration. +- **Mandatory Commands**: + - `dotnet ef migrations add --project src/NexusReader.Data --startup-project src/NexusReader.Web.New` + - `dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web.New` +- Ensure the migration is applied to all local development environments before proceeding with feature verification. + ## Audit Scripts - [ArchCheck.sh](scripts/arch_check.sh): A shell script to scan for illegal cross-layer imports. diff --git a/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs b/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs index 2c6a4a2..4fedcf1 100644 --- a/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs +++ b/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs @@ -3,4 +3,10 @@ using MediatR; namespace NexusReader.Application.Commands.Sync; -public record UpdateReadingProgressCommand(string PageId, string UserId, string? ExcludedConnectionId = null) : IRequest; +public record UpdateReadingProgressCommand( + string PageId, + string UserId, + Guid EbookId, + double Progress, + string? ChapterTitle, + string? ExcludedConnectionId = null) : IRequest; diff --git a/src/NexusReader.Application/Queries/Reader/ViewModels.cs b/src/NexusReader.Application/Queries/Reader/ViewModels.cs index 25f2395..2b5fbc4 100644 --- a/src/NexusReader.Application/Queries/Reader/ViewModels.cs +++ b/src/NexusReader.Application/Queries/Reader/ViewModels.cs @@ -8,4 +8,4 @@ public abstract record ContentBlock(string Id); public record TextSegmentBlock(string Id, string Content) : ContentBlock(Id); public record AiActionTriggerBlock(string Id, string Dialogue, List ActionOptions) : ContentBlock(Id); -public record ReaderPageViewModel(List Blocks, int CurrentChapterIndex, int TotalChapters, string ChapterTitle); +public record ReaderPageViewModel(List Blocks, int CurrentChapterIndex, int TotalChapters, string ChapterTitle, Guid EbookId = default); diff --git a/src/NexusReader.Application/Queries/User/GetUserProfileQuery.cs b/src/NexusReader.Application/Queries/User/GetUserProfileQuery.cs new file mode 100644 index 0000000..fb60b77 --- /dev/null +++ b/src/NexusReader.Application/Queries/User/GetUserProfileQuery.cs @@ -0,0 +1,7 @@ +using MediatR; +using FluentResults; +using NexusReader.Application.DTOs.User; + +namespace NexusReader.Application.Queries.User; + +public record GetUserProfileQuery(string UserId) : IRequest>; diff --git a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs new file mode 100644 index 0000000..dca8317 --- /dev/null +++ b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs @@ -0,0 +1,60 @@ +using MediatR; +using FluentResults; +using Microsoft.EntityFrameworkCore; +using NexusReader.Application.DTOs.User; +using NexusReader.Data.Persistence; + +namespace NexusReader.Application.Queries.User; + +public class GetUserProfileQueryHandler : IRequestHandler> +{ + private readonly AppDbContext _dbContext; + + public GetUserProfileQueryHandler(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) + { + var profile = await _dbContext.Users + .Where(u => u.Id == request.UserId) + .Select(u => new UserProfileDto + { + Email = u.Email ?? string.Empty, + AITokensUsed = u.AITokensUsed, + TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty, + Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto + { + Id = u.SubscriptionPlan.Id, + Name = u.SubscriptionPlan.PlanName, + AITokenLimit = u.SubscriptionPlan.AITokenLimit, + MonthlyPrice = u.SubscriptionPlan.MonthlyPrice + } : new SubscriptionPlanDto(), + AverageQuizScore = u.QuizResults.Any(q => q.TotalQuestions > 0) + ? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100) + : 0, + LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto + { + Id = e.Id, + Title = e.Title, + Author = new AuthorDto + { + Id = e.Author.Id, + Name = e.Author.Name + }, + CoverUrl = e.CoverUrl, + Progress = e.Progress, + LastChapter = e.LastChapter ?? "Rozpoczynanie..." + }).FirstOrDefault() + }) + .FirstOrDefaultAsync(cancellationToken); + + if (profile == null) + { + return Result.Fail("Profile not found."); + } + + return Result.Ok(profile); + } +} diff --git a/src/NexusReader.Data/Migrations/20260510151022_NormalizeAuthor.Designer.cs b/src/NexusReader.Data/Migrations/20260510151022_NormalizeAuthor.Designer.cs new file mode 100644 index 0000000..910c587 --- /dev/null +++ b/src/NexusReader.Data/Migrations/20260510151022_NormalizeAuthor.Designer.cs @@ -0,0 +1,690 @@ +// +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("20260510151022_NormalizeAuthor")] + partial class NormalizeAuthor + { + /// + 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("LastReadDate") + .HasColumnType("timestamp with time zone"); + + 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/20260510151022_NormalizeAuthor.cs b/src/NexusReader.Data/Migrations/20260510151022_NormalizeAuthor.cs new file mode 100644 index 0000000..c8ef67d --- /dev/null +++ b/src/NexusReader.Data/Migrations/20260510151022_NormalizeAuthor.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NexusReader.Data.Migrations +{ + /// + public partial class NormalizeAuthor : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Author", + table: "Ebooks"); + + migrationBuilder.AddColumn( + name: "AuthorId", + table: "Ebooks", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "Authors", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Authors", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Ebooks_AuthorId", + table: "Ebooks", + column: "AuthorId"); + + migrationBuilder.AddForeignKey( + name: "FK_Ebooks_Authors_AuthorId", + table: "Ebooks", + column: "AuthorId", + principalTable: "Authors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Ebooks_Authors_AuthorId", + table: "Ebooks"); + + migrationBuilder.DropTable( + name: "Authors"); + + migrationBuilder.DropIndex( + name: "IX_Ebooks_AuthorId", + table: "Ebooks"); + + migrationBuilder.DropColumn( + name: "AuthorId", + table: "Ebooks"); + + migrationBuilder.AddColumn( + name: "Author", + table: "Ebooks", + type: "character varying(255)", + maxLength: 255, + nullable: false, + defaultValue: ""); + } + } +} diff --git a/src/NexusReader.Data/Migrations/20260510161155_AddEbookProgressAndChapter.Designer.cs b/src/NexusReader.Data/Migrations/20260510161155_AddEbookProgressAndChapter.Designer.cs new file mode 100644 index 0000000..94660c9 --- /dev/null +++ b/src/NexusReader.Data/Migrations/20260510161155_AddEbookProgressAndChapter.Designer.cs @@ -0,0 +1,697 @@ +// +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("20260510161155_AddEbookProgressAndChapter")] + partial class AddEbookProgressAndChapter + { + /// + 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("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/20260510161155_AddEbookProgressAndChapter.cs b/src/NexusReader.Data/Migrations/20260510161155_AddEbookProgressAndChapter.cs new file mode 100644 index 0000000..3219f3a --- /dev/null +++ b/src/NexusReader.Data/Migrations/20260510161155_AddEbookProgressAndChapter.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusReader.Data.Migrations +{ + /// + public partial class AddEbookProgressAndChapter : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastChapter", + table: "Ebooks", + type: "character varying(255)", + maxLength: 255, + nullable: true); + + migrationBuilder.AddColumn( + name: "Progress", + table: "Ebooks", + type: "double precision", + nullable: false, + defaultValue: 0.0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastChapter", + table: "Ebooks"); + + migrationBuilder.DropColumn( + name: "Progress", + table: "Ebooks"); + } + } +} diff --git a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs index 9be4777..6fbdc71 100644 --- a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs @@ -156,6 +156,24 @@ namespace NexusReader.Data.Migrations 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") @@ -165,10 +183,8 @@ namespace NexusReader.Data.Migrations b.Property("AddedDate") .HasColumnType("timestamp with time zone"); - b.Property("Author") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); + b.Property("AuthorId") + .HasColumnType("integer"); b.Property("CoverUrl") .HasColumnType("text"); @@ -177,9 +193,16 @@ namespace NexusReader.Data.Migrations .IsRequired() .HasColumnType("text"); + b.Property("LastChapter") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + b.Property("LastReadDate") .HasColumnType("timestamp with time zone"); + b.Property("Progress") + .HasColumnType("double precision"); + b.Property("TenantId") .IsRequired() .HasMaxLength(128) @@ -196,11 +219,13 @@ namespace NexusReader.Data.Migrations b.HasKey("Id"); + b.HasIndex("AuthorId"); + b.HasIndex("TenantId"); b.HasIndex("UserId"); - b.ToTable("Ebooks", (string)null); + b.ToTable("Ebooks"); }); modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => @@ -246,7 +271,7 @@ namespace NexusReader.Data.Migrations b.HasIndex("TenantId"); - b.ToTable("KnowledgeUnits", (string)null); + b.ToTable("KnowledgeUnits"); }); modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b => @@ -278,7 +303,7 @@ namespace NexusReader.Data.Migrations b.HasIndex("TargetUnitId"); - b.ToTable("KnowledgeUnitLinks", (string)null); + b.ToTable("KnowledgeUnitLinks"); }); modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => @@ -413,7 +438,7 @@ namespace NexusReader.Data.Migrations b.HasIndex("UserId"); - b.ToTable("QuizResults", (string)null); + b.ToTable("QuizResults"); }); modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b => @@ -458,7 +483,7 @@ namespace NexusReader.Data.Migrations b.HasIndex("TenantId"); - b.ToTable("SemanticKnowledgeCache", (string)null); + b.ToTable("SemanticKnowledgeCache"); }); modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b => @@ -493,7 +518,7 @@ namespace NexusReader.Data.Migrations b.HasIndex("PlanName") .IsUnique(); - b.ToTable("SubscriptionPlans", (string)null); + b.ToTable("SubscriptionPlans"); b.HasData( new @@ -587,12 +612,20 @@ namespace NexusReader.Data.Migrations 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"); }); @@ -637,6 +670,11 @@ namespace NexusReader.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("NexusReader.Domain.Entities.Author", b => + { + b.Navigation("Ebooks"); + }); + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => { b.Navigation("IncomingLinks"); diff --git a/src/NexusReader.Data/Persistence/DbInitializer.cs b/src/NexusReader.Data/Persistence/DbInitializer.cs index 3a30e35..0e86317 100644 --- a/src/NexusReader.Data/Persistence/DbInitializer.cs +++ b/src/NexusReader.Data/Persistence/DbInitializer.cs @@ -78,6 +78,33 @@ public static class DbInitializer await dbContext.SaveChangesAsync(); Console.WriteLine($"[Seeder] Admin user created successfully: {adminEmail}"); + + // Seed Sample Author + var author = await dbContext.Authors.FirstOrDefaultAsync(a => a.Name == "Giorgio Vasari"); + if (author == null) + { + author = new Author { Name = "Giorgio Vasari" }; + dbContext.Authors.Add(author); + await dbContext.SaveChangesAsync(); + } + + // Seed Sample Ebook + if (!dbContext.Ebooks.Any(e => e.UserId == adminUser.Id)) + { + dbContext.Ebooks.Add(new Ebook + { + Title = "Lives of the Most Excellent Painters, Sculptors, and Architects", + AuthorId = author.Id, + UserId = adminUser.Id, + FilePath = "wwwroot/assets/book.epub", + AddedDate = DateTime.UtcNow, + LastReadDate = DateTime.UtcNow, + Progress = 0, + LastChapter = "Introduction" + }); + await dbContext.SaveChangesAsync(); + Console.WriteLine("[Seeder] Sample book seeded for admin."); + } } else { diff --git a/src/NexusReader.Domain/Entities/Ebook.cs b/src/NexusReader.Domain/Entities/Ebook.cs index cc12e43..14466e7 100644 --- a/src/NexusReader.Domain/Entities/Ebook.cs +++ b/src/NexusReader.Domain/Entities/Ebook.cs @@ -33,6 +33,11 @@ public class Ebook public DateTime AddedDate { get; set; } = DateTime.UtcNow; public DateTime? LastReadDate { get; set; } + + public double Progress { get; set; } = 0; + + [MaxLength(255)] + public string? LastChapter { get; set; } // Relationship to NexusUser [Required] diff --git a/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs b/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs index f82a82c..8845320 100644 --- a/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs +++ b/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs @@ -35,6 +35,15 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler e.Id == request.EbookId, cancellationToken); + if (ebook != null) + { + ebook.Progress = request.Progress; + ebook.LastChapter = request.ChapterTitle; + ebook.LastReadDate = now; + } + await context.SaveChangesAsync(cancellationToken); // Broadcast to other devices diff --git a/src/NexusReader.Infrastructure/RealTime/SyncHub.cs b/src/NexusReader.Infrastructure/RealTime/SyncHub.cs index 0d35f05..78015f5 100644 --- a/src/NexusReader.Infrastructure/RealTime/SyncHub.cs +++ b/src/NexusReader.Infrastructure/RealTime/SyncHub.cs @@ -15,12 +15,12 @@ public class SyncHub : Hub _mediator = mediator; } - public async Task UpdateProgress(string pageId) + public async Task UpdateProgress(string pageId, Guid ebookId, double progress, string? chapterTitle) { var userId = Context.UserIdentifier; if (!string.IsNullOrEmpty(userId)) { - await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, Context.ConnectionId)); + await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, ebookId, progress, chapterTitle, Context.ConnectionId)); } } diff --git a/src/NexusReader.Infrastructure/Services/EpubService.cs b/src/NexusReader.Infrastructure/Services/EpubService.cs index e7a5dd0..d3b94f3 100644 --- a/src/NexusReader.Infrastructure/Services/EpubService.cs +++ b/src/NexusReader.Infrastructure/Services/EpubService.cs @@ -4,14 +4,23 @@ using FluentResults; using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Queries.Reader; using VersOne.Epub; +using Microsoft.EntityFrameworkCore; +using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; namespace NexusReader.Infrastructure.Services; public class EpubService : IEpubService { + private readonly IDbContextFactory _dbContextFactory; private const string EpubPath = "wwwroot/assets/book.epub"; private const int WordThreshold = 1000; + public EpubService(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + public async Task> GetEpubContentAsync(int chapterIndex) { try @@ -100,7 +109,14 @@ public class EpubService : IEpubService blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}")); } - return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle)); + // Find the EbookId from DB for this file + using var context = await _dbContextFactory.CreateDbContextAsync(); + var ebookId = await context.Ebooks + .Where(e => e.FilePath.Contains("book.epub")) + .Select(e => e.Id) + .FirstOrDefaultAsync(); + + return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle, ebookId)); } catch (Exception ex) { diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index d6cd288..6bc34be 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -118,8 +118,18 @@ { await Coordinator.OnBlockReachedAsync(blockId, content); - // Debounce sync update (simple version: every 5 seconds or on a timer) - await SyncService.UpdateProgressAsync(blockId); + if (ViewModel != null) + { + // Calculate progress: (CurrentChapter / TotalChapters) * 100 + // Simple approximation for now: chapter-based + double progress = ((double)(ViewModel.CurrentChapterIndex + 1) / ViewModel.TotalChapters) * 100; + + await SyncService.UpdateProgressAsync( + blockId, + ViewModel.EbookId, + progress, + ViewModel.ChapterTitle); + } } private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp) diff --git a/src/NexusReader.UI.Shared/Services/ISyncService.cs b/src/NexusReader.UI.Shared/Services/ISyncService.cs index 35d1529..80cd7aa 100644 --- a/src/NexusReader.UI.Shared/Services/ISyncService.cs +++ b/src/NexusReader.UI.Shared/Services/ISyncService.cs @@ -5,7 +5,7 @@ namespace NexusReader.UI.Shared.Services; public interface ISyncService { Task InitializeAsync(); - Task UpdateProgressAsync(string pageId); + Task UpdateProgressAsync(string pageId, Guid ebookId, double progress, string? chapterTitle); event Func OnProgressReceived; Task DisposeAsync(); } diff --git a/src/NexusReader.UI.Shared/Services/SyncService.cs b/src/NexusReader.UI.Shared/Services/SyncService.cs index 8fb515d..8a4d841 100644 --- a/src/NexusReader.UI.Shared/Services/SyncService.cs +++ b/src/NexusReader.UI.Shared/Services/SyncService.cs @@ -46,6 +46,7 @@ public class SyncService : ISyncService, IAsyncDisposable _hubConnection.On("ProgressUpdated", async (pageId, timestamp) => { + // Note: In the future we might want to receive ebookId and progress here too if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp); }); @@ -63,7 +64,7 @@ public class SyncService : ISyncService, IAsyncDisposable private string? _lastSentPageId; - public async Task UpdateProgressAsync(string pageId) + public async Task UpdateProgressAsync(string pageId, Guid ebookId, double progress, string? chapterTitle) { if (pageId == _lastSentPageId) return Result.Ok(); @@ -82,7 +83,7 @@ public class SyncService : ISyncService, IAsyncDisposable if (_hubConnection?.State == HubConnectionState.Connected) { - await _hubConnection.SendAsync("UpdateProgress", pageId, token); + await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, token); _lastSentPageId = pageId; } } diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs index 4b2dd37..b8c3d4d 100644 --- a/src/NexusReader.Web.New/Program.cs +++ b/src/NexusReader.Web.New/Program.cs @@ -2,7 +2,8 @@ using NexusReader.Web.Components; using NexusReader.Application; using NexusReader.Infrastructure; using NexusReader.Application.Abstractions.Services; -using NexusReader.Application.DTOs.User; +using NexusReader.Application.Queries.User; +using MediatR; using NexusReader.Web.Client.Services; using NexusReader.UI.Shared.Services; using NexusReader.Domain.Entities; @@ -430,49 +431,15 @@ app.MapGet("/identity/callback/google", async ( return Results.Redirect("/account/login?error=ProvisioningFailed"); }); -app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager userManager, IDbContextFactory dbContextFactory) => +app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (userId == null) return Results.Unauthorized(); - using var dbContext = await dbContextFactory.CreateDbContextAsync(); + var result = await mediator.Send(new GetUserProfileQuery(userId)); + if (result.IsFailed) return Results.NotFound(result.Errors.FirstOrDefault()?.Message); - var profile = await dbContext.Users - .Where(u => u.Id == userId) - .Select(u => new UserProfileDto - { - Email = u.Email ?? string.Empty, - AITokensUsed = u.AITokensUsed, - TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty, - Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto - { - Id = u.SubscriptionPlan.Id, - Name = u.SubscriptionPlan.PlanName, - AITokenLimit = u.SubscriptionPlan.AITokenLimit, - MonthlyPrice = u.SubscriptionPlan.MonthlyPrice - } : new SubscriptionPlanDto(), - AverageQuizScore = u.QuizResults.Any(q => q.TotalQuestions > 0) - ? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100) - : 0, - LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto - { - Id = e.Id, - Title = e.Title, - Author = new AuthorDto - { - Id = e.Author.Id, - Name = e.Author.Name - }, - CoverUrl = e.CoverUrl, - Progress = 65, - LastChapter = "Chapter 4: Renaissance in Italy" - }).FirstOrDefault() - }) - .FirstOrDefaultAsync(); - - if (profile == null) return Results.NotFound(); - - return Results.Ok(profile); + return Results.Ok(result.Value); }).RequireAuthorization(); app.MapRazorComponents() diff --git a/src/NexusReader.Web.New/Services/ServerIdentityService.cs b/src/NexusReader.Web.New/Services/ServerIdentityService.cs index 95d866c..bac61e2 100644 --- a/src/NexusReader.Web.New/Services/ServerIdentityService.cs +++ b/src/NexusReader.Web.New/Services/ServerIdentityService.cs @@ -5,6 +5,8 @@ using Microsoft.EntityFrameworkCore; using NexusReader.Application.DTOs.User; using NexusReader.Data.Persistence; using NexusReader.Domain.Entities; +using NexusReader.Application.Queries.User; +using MediatR; using NexusReader.UI.Shared.Services; namespace NexusReader.Web.New.Services; @@ -13,18 +15,18 @@ public class ServerIdentityService : IIdentityService { private readonly UserManager _userManager; private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IDbContextFactory _dbContextFactory; + private readonly IMediator _mediator; public event Func? OnStateInvalidated; public ServerIdentityService( UserManager userManager, IHttpContextAccessor httpContextAccessor, - IDbContextFactory dbContextFactory) + IMediator mediator) { _userManager = userManager; _httpContextAccessor = httpContextAccessor; - _dbContextFactory = dbContextFactory; + _mediator = mediator; } public Task LoginAsync(string email, string password, bool rememberMe = false) @@ -46,40 +48,21 @@ public class ServerIdentityService : IIdentityService var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (userId == null) return Result.Fail("User ID not found."); - using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - - var profile = await dbContext.Users - .Where(u => u.Id == userId) - .Select(u => new UserProfile( - u.Email ?? string.Empty, - u.AITokensUsed, - u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty, - u.SubscriptionPlan != null ? new SubscriptionPlanDto - { - Id = u.SubscriptionPlan.Id, - Name = u.SubscriptionPlan.PlanName, - AITokenLimit = u.SubscriptionPlan.AITokenLimit, - MonthlyPrice = u.SubscriptionPlan.MonthlyPrice - } : new SubscriptionPlanDto(), - u.QuizResults.Any(q => q.TotalQuestions > 0) - ? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100) - : 0, - u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto - { - Id = e.Id, - Title = e.Title, - Author = new AuthorDto - { - Id = e.Author.Id, - Name = e.Author.Name - }, - CoverUrl = e.CoverUrl, - Progress = 65, // Hardcoded for now as per design requirements, will link to real segments later - LastChapter = "Chapter 4: Renaissance in Italy" - }).FirstOrDefault() - )) - .FirstOrDefaultAsync(); + var result = await _mediator.Send(new GetUserProfileQuery(userId)); + if (result.IsFailed) return Result.Fail(result.Errors); - return profile != null ? Result.Ok(profile) : Result.Fail("Profile not found."); + var dto = result.Value; + + // Map DTO to UI record + var profile = new UserProfile( + dto.Email, + dto.AITokensUsed, + dto.TenantId, + dto.Plan, + dto.AverageQuizScore, + dto.LastReadBook + ); + + return Result.Ok(profile); } }