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/.agent/skills/nexus-ui-engine/SKILL.md b/.agent/skills/nexus-ui-engine/SKILL.md index 120a706..4c5eba8 100644 --- a/.agent/skills/nexus-ui-engine/SKILL.md +++ b/.agent/skills/nexus-ui-engine/SKILL.md @@ -43,4 +43,7 @@ description: Design System & Component rules for Blazor - **Interactive Flow:** - AI Assistant interactions must be non-blocking and smoothly transition using CSS animations. - - Interactive elements must have clear `:hover`, `:active`, and `:focus` states. \ No newline at end of file + - Interactive elements must have clear `:hover`, `:active`, and `:focus` states. + - **Glass Panel Standard:** All primary data panels (`.glass-panel`) must implement the following interaction signature: + - `transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)` + - `:hover` state must include: `transform: translateY(-4px)`, increased background opacity, and a subtle `--nexus-neon` border highlight (e.g., `rgba(0, 255, 153, 0.2)`). \ No newline at end of file diff --git a/src/NexusReader.Application/Abstractions/Services/IEpubService.cs b/src/NexusReader.Application/Abstractions/Services/IEpubService.cs index d6ff680..188988c 100644 --- a/src/NexusReader.Application/Abstractions/Services/IEpubService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IEpubService.cs @@ -5,5 +5,5 @@ namespace NexusReader.Application.Abstractions.Services; public interface IEpubService { - Task> GetEpubContentAsync(int chapterIndex); + Task> GetEpubContentAsync(int chapterIndex, string? userId = null); } diff --git a/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs b/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs index 2c6a4a2..0f6dca8 100644 --- a/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs +++ b/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs @@ -3,4 +3,11 @@ 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, + int ChapterIndex, + string? ExcludedConnectionId = null) : IRequest; diff --git a/src/NexusReader.Application/DTOs/User/AuthorDto.cs b/src/NexusReader.Application/DTOs/User/AuthorDto.cs new file mode 100644 index 0000000..8c3a3fc --- /dev/null +++ b/src/NexusReader.Application/DTOs/User/AuthorDto.cs @@ -0,0 +1,7 @@ +namespace NexusReader.Application.DTOs.User; + +public record AuthorDto +{ + public int Id { get; init; } + public string Name { get; init; } = string.Empty; +} diff --git a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs index 8bbe4f2..b85ddfd 100644 --- a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs +++ b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs @@ -4,6 +4,7 @@ public record UserProfileDto { public string Email { get; init; } = string.Empty; public int AITokensUsed { get; init; } + public Guid TenantId { get; init; } /// /// Relational data for the current subscription plan. @@ -22,4 +23,9 @@ public record LastReadBookDto { public Guid Id { get; init; } public string Title { get; init; } = string.Empty; + public AuthorDto Author { get; init; } = new(); + public string? CoverUrl { get; init; } + public double Progress { get; init; } + public string? LastChapter { get; init; } + public int LastChapterIndex { get; init; } } diff --git a/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs b/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs index 132a3f4..4cc2e9e 100644 --- a/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs +++ b/src/NexusReader.Application/Queries/Reader/GetReaderPageQuery.cs @@ -2,4 +2,4 @@ using NexusReader.Application.Abstractions.Messaging; namespace NexusReader.Application.Queries.Reader; -public record GetReaderPageQuery(int ChapterIndex = 0) : IQuery; +public record GetReaderPageQuery(int ChapterIndex = 0, string? UserId = null) : IQuery; diff --git a/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs b/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs index d12cd5c..a7fb52d 100644 --- a/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs +++ b/src/NexusReader.Application/Queries/Reader/GetReaderPageQueryHandler.cs @@ -15,6 +15,6 @@ internal sealed class GetReaderPageQueryHandler : IQueryHandler> Handle(GetReaderPageQuery request, CancellationToken cancellationToken) { - return await _epubService.GetEpubContentAsync(request.ChapterIndex); + return await _epubService.GetEpubContentAsync(request.ChapterIndex, request.UserId); } } 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..b6fe031 --- /dev/null +++ b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs @@ -0,0 +1,62 @@ +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 IDbContextFactory _dbContextFactory; + + public GetUserProfileQueryHandler(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) + { + using var dbContext = await _dbContextFactory.CreateDbContextAsync(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...", + LastChapterIndex = e.LastChapterIndex + }).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/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 9be4777..3b0cb08 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,19 @@ namespace NexusReader.Data.Migrations .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) @@ -196,11 +222,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 +274,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 +306,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 +441,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 +486,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 +521,7 @@ namespace NexusReader.Data.Migrations b.HasIndex("PlanName") .IsUnique(); - b.ToTable("SubscriptionPlans", (string)null); + b.ToTable("SubscriptionPlans"); b.HasData( new @@ -587,12 +615,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 +673,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/AppDbContext.cs b/src/NexusReader.Data/Persistence/AppDbContext.cs index 16ed15e..03f8cf8 100644 --- a/src/NexusReader.Data/Persistence/AppDbContext.cs +++ b/src/NexusReader.Data/Persistence/AppDbContext.cs @@ -24,6 +24,7 @@ public class AppDbContext : IdentityDbContext public DbSet Ebooks => Set(); public DbSet QuizResults => Set(); public DbSet SubscriptionPlans => Set(); + public DbSet Authors => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -88,6 +89,11 @@ public class AppDbContext : IdentityDbContext .WithMany(u => u.Ebooks) .HasForeignKey(e => e.UserId) .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.Author) + .WithMany(a => a.Ebooks) + .HasForeignKey(e => e.AuthorId) + .OnDelete(DeleteBehavior.Restrict); entity.HasIndex(e => e.TenantId); }); 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/Author.cs b/src/NexusReader.Domain/Entities/Author.cs new file mode 100644 index 0000000..5f15836 --- /dev/null +++ b/src/NexusReader.Domain/Entities/Author.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace NexusReader.Domain.Entities; + +public class Author +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(255)] + public string Name { get; set; } = string.Empty; + + public virtual ICollection Ebooks { get; set; } = new List(); +} diff --git a/src/NexusReader.Domain/Entities/Ebook.cs b/src/NexusReader.Domain/Entities/Ebook.cs index 582f23b..be5aaf7 100644 --- a/src/NexusReader.Domain/Entities/Ebook.cs +++ b/src/NexusReader.Domain/Entities/Ebook.cs @@ -15,8 +15,11 @@ public class Ebook [MaxLength(255)] public string Title { get; set; } = string.Empty; - [MaxLength(255)] - public string Author { get; set; } = "Unknown"; + [Required] + public int AuthorId { get; set; } + + [ForeignKey(nameof(AuthorId))] + public virtual Author Author { get; set; } = null!; [Required] public string FilePath { get; set; } = string.Empty; @@ -30,6 +33,13 @@ 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; } + + public int LastChapterIndex { get; set; } = 0; // Relationship to NexusUser [Required] diff --git a/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs b/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs index f82a82c..2efe200 100644 --- a/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs +++ b/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs @@ -35,6 +35,16 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler e.Id == request.EbookId, cancellationToken); + if (ebook != null) + { + ebook.Progress = request.Progress; + ebook.LastChapter = request.ChapterTitle; + ebook.LastChapterIndex = request.ChapterIndex; + 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..1423e4a 100644 --- a/src/NexusReader.Infrastructure/RealTime/SyncHub.cs +++ b/src/NexusReader.Infrastructure/RealTime/SyncHub.cs @@ -2,6 +2,7 @@ using MediatR; using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.Authorization; using NexusReader.Application.Commands.Sync; +using System.Security.Claims; namespace NexusReader.Infrastructure.RealTime; @@ -15,12 +16,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, int chapterIndex) { - var userId = Context.UserIdentifier; - if (!string.IsNullOrEmpty(userId)) + var userId = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId != null) { - await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, Context.ConnectionId)); + await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, ebookId, progress, chapterTitle, chapterIndex, Context.ConnectionId)); } } diff --git a/src/NexusReader.Infrastructure/Services/EpubService.cs b/src/NexusReader.Infrastructure/Services/EpubService.cs index e7a5dd0..b516625 100644 --- a/src/NexusReader.Infrastructure/Services/EpubService.cs +++ b/src/NexusReader.Infrastructure/Services/EpubService.cs @@ -4,15 +4,24 @@ 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 async Task> GetEpubContentAsync(int chapterIndex) + public EpubService(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task> GetEpubContentAsync(int chapterIndex, string? userId = null) { try { @@ -100,7 +109,29 @@ 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 AND this user + using var context = await _dbContextFactory.CreateDbContextAsync(); + var ebook = await context.Ebooks + .Where(e => e.FilePath.Contains("book.epub") && (userId == null || e.UserId == userId)) + .FirstOrDefaultAsync(); + + // Auto-provision if not found for this user (convenience for dev) + if (ebook == null && !string.IsNullOrEmpty(userId)) + { + var author = await context.Authors.FirstOrDefaultAsync() ?? new Author { Name = "Unknown Author" }; + ebook = new Ebook + { + Title = "Lives of the Most Excellent Painters, Sculptors, and Architects", + FilePath = "wwwroot/assets/book.epub", + UserId = userId, + Author = author, + TenantId = "global" + }; + context.Ebooks.Add(ebook); + await context.SaveChangesAsync(); + } + + return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle, ebook?.Id ?? Guid.Empty)); } catch (Exception ex) { diff --git a/src/NexusReader.Maui/Main.razor b/src/NexusReader.Maui/Main.razor index 89cac43..29c775f 100644 --- a/src/NexusReader.Maui/Main.razor +++ b/src/NexusReader.Maui/Main.razor @@ -3,7 +3,7 @@ - + @@ -11,7 +11,7 @@ - +

Sorry, there's nothing at this address.

diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor index 3d73d8f..5bc025a 100644 --- a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor @@ -1,6 +1,27 @@ @switch (Name.ToLowerInvariant()) { + case "home": + + + break; + case "map": + + + + break; + case "share-2": + + + + + + break; + case "help-circle": + + + + break; case "robot": break; @@ -16,8 +37,24 @@ case "message-square": break; + case "diamond": + + break; + case "layout": + + + + break; + case "book-open": + + + break; + case "user": + + + break; case "settings": - + break; case "bookmark": diff --git a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor index 9e4e831..6561265 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor @@ -7,8 +7,8 @@
- - +
+ + + + +
+
+ @Body +
+
+ + +@code { + [Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!; + [Inject] private IIdentityService IdentityService { get; set; } = default!; + [Inject] private NavigationManager NavigationManager { get; set; } = default!; + + private bool _isSyncing = false; + + protected override async Task OnInitializedAsync() + { + if (_isSyncing) return; + + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (!authState.User.Identity?.IsAuthenticated ?? true) + { + _isSyncing = true; + // Try to sync with server cookie + await IdentityService.GetProfileAsync(); + } + } + + private async Task HandleLogout() + { + await IdentityService.LogoutAsync(); + NavigationManager.NavigateTo("/", true); + } +} diff --git a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css new file mode 100644 index 0000000..6a9b48c --- /dev/null +++ b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css @@ -0,0 +1,193 @@ +.hub-container { + display: flex; + width: 100vw; + height: 100vh; + background: #121212; + color: #e0e0e0; + overflow: hidden; +} + +::deep .hub-sidebar { + width: 260px; + height: 100%; + background: #161616; + border-right: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + z-index: 100; + flex-shrink: 0; +} + +::deep .sidebar-header { + padding: 2.5rem 1.5rem; +} + +::deep .logo { + display: flex; + align-items: center; + gap: 0.75rem; +} + +::deep .logo-icon { + color: var(--nexus-neon); + filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.4)); +} + +::deep .logo-text { + font-family: var(--nexus-font-serif); + font-size: 1.5rem; + font-weight: 700; + color: #ffffff; + letter-spacing: -0.01em; +} + +::deep .sidebar-nav { + flex: 1; + padding: 0; + display: flex; + flex-direction: column; +} + +::deep .nav-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.5rem; + color: #A0A0A0; + text-decoration: none; + transition: all 0.2s ease; + border-left: 3px solid transparent; + font-family: var(--nexus-font-sans); + font-size: 0.9rem; + font-weight: 500; +} + +::deep .nav-item:hover { + background: rgba(255, 255, 255, 0.02); + color: #ffffff; +} + +::deep .nav-item.active { + color: #ffffff; + background: rgba(0, 255, 153, 0.03); + border-left: 3px solid var(--nexus-neon); +} + +::deep .nav-item.active .nav-icon { + color: var(--nexus-neon); +} + +::deep .nav-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + opacity: 0.7; + transition: opacity 0.2s; +} + +::deep .nav-item:hover .nav-icon, +::deep .nav-item.active .nav-icon { + opacity: 1; +} + +::deep .sidebar-footer { + padding: 1.25rem 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: space-between; +} + +::deep .user-brief { + display: flex; + align-items: center; + gap: 0.75rem; + overflow: hidden; +} + +::deep .user-avatar { + width: 32px; + height: 32px; + background: #222; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 600; + color: #A0A0A0; + flex-shrink: 0; +} + +::deep .user-details { + display: flex; + flex-direction: column; + overflow: hidden; +} + +::deep .user-name { + font-size: 0.85rem; + font-weight: 500; + color: #A0A0A0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +::deep .logout-btn { + background: transparent; + border: none; + color: #666; + cursor: pointer; + padding: 0.4rem; + border-radius: 6px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +::deep .logout-btn:hover { + background: rgba(255, 255, 255, 0.05); + color: #ffffff; +} + +.hub-main { + flex: 1; + height: 100%; + overflow-y: auto; + background: radial-gradient(circle at center, #1a1a1a 0%, #121212 100%); +} + +.hub-content { + padding: 2.5rem; + min-height: 100%; +} + +::deep .hub-loading { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1.5rem; +} + +::deep .nexus-loader { + width: 32px; + height: 32px; + border: 2px solid rgba(0, 255, 153, 0.1); + border-top-color: var(--nexus-neon); + border-radius: 50%; + animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; + filter: drop-shadow(0 0 5px var(--nexus-neon)); +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + + diff --git a/src/NexusReader.UI.Shared/Layout/MainLayout.razor b/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor similarity index 80% rename from src/NexusReader.UI.Shared/Layout/MainLayout.razor rename to src/NexusReader.UI.Shared/Layout/ReaderLayout.razor index 5b3bd3a..0c2f55c 100644 --- a/src/NexusReader.UI.Shared/Layout/MainLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor @@ -10,19 +10,23 @@ @inject IJSRuntime JS @inject IIdentityService IdentityService @inject NavigationManager NavigationManager -@inject Microsoft.Extensions.Logging.ILogger Logger +@inject Microsoft.Extensions.Logging.ILogger Logger @implements IDisposable - - -
-
-
- @Body -
+
+
+
+ @Body +
+ + -
+ + +
+ +
@@ -34,9 +38,6 @@ Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" /> Asystent AI
- - -
@@ -49,18 +50,15 @@
- -
- -
-
-
Weryfikacja...
-
-
- - @Body - -
+ + +
+
+
Weryfikacja...
+
+
+ +
An unhandled error has occurred. diff --git a/src/NexusReader.UI.Shared/Layout/MainLayout.razor.css b/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor.css similarity index 100% rename from src/NexusReader.UI.Shared/Layout/MainLayout.razor.css rename to src/NexusReader.UI.Shared/Layout/ReaderLayout.razor.css diff --git a/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj b/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj index d152f11..31839bf 100644 --- a/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj +++ b/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj @@ -12,6 +12,7 @@ + diff --git a/src/NexusReader.UI.Shared/Pages/Account/Login.razor b/src/NexusReader.UI.Shared/Pages/Account/Login.razor index 5d54751..3feec48 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Login.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Login.razor @@ -9,7 +9,7 @@