From 311eaa8b0431255f0ed0f5141ede7638140efd02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 5 May 2026 15:07:48 +0200 Subject: [PATCH] feat: normalize subscription architecture, integrate pgvector, and implement Stripe webhook subscription management. --- .../skills/nexus-clean-architecture/SKILL.md | 1 + backlog-code-review.md | 83 +-- docker-compose.yml | 2 +- .../DTOs/User/SubscriptionPlanDto.cs | 9 + .../DTOs/User/UserProfileDto.cs | 25 + .../Library/SearchLibrarySemanticallyQuery.cs | 3 +- .../Security/Authorization/ProUserHandler.cs | 2 +- src/NexusReader.Domain/Entities/Ebook.cs | 4 + .../Entities/KnowledgeUnit.cs | 4 +- src/NexusReader.Domain/Entities/NexusUser.cs | 34 +- src/NexusReader.Domain/Entities/QuizResult.cs | 4 + .../Entities/SemanticKnowledgeCache.cs | 4 +- .../Entities/SubscriptionPlan.cs | 30 + .../NexusReader.Domain.csproj | 1 + .../Configuration/AiSettings.cs | 1 + .../DependencyInjection.cs | 13 +- ...alizedSubscriptionArchitecture.Designer.cs | 652 ++++++++++++++++++ ...FinalNormalizedSubscriptionArchitecture.cs | 399 +++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 250 ++++++- .../NexusReader.Infrastructure.csproj | 1 + .../Persistence/AppDbContext.cs | 34 +- .../Persistence/AppDbContextFactory.cs | 45 ++ .../Persistence/DbInitializer.cs | 18 +- .../Services/BillingService.cs | 33 +- .../Services/EpubService.cs | 15 +- .../Services/KnowledgeService.cs | 147 ++-- .../Components/Organisms/ReaderCanvas.razor | 1 + .../Services/KnowledgeCoordinator.cs | 6 + src/NexusReader.Web.New/Program.cs | 77 ++- 29 files changed, 1699 insertions(+), 199 deletions(-) create mode 100644 src/NexusReader.Application/DTOs/User/SubscriptionPlanDto.cs create mode 100644 src/NexusReader.Application/DTOs/User/UserProfileDto.cs create mode 100644 src/NexusReader.Domain/Entities/SubscriptionPlan.cs create mode 100644 src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.Designer.cs create mode 100644 src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.cs create mode 100644 src/NexusReader.Infrastructure/Persistence/AppDbContextFactory.cs diff --git a/.agent/skills/nexus-clean-architecture/SKILL.md b/.agent/skills/nexus-clean-architecture/SKILL.md index ad0f79c..ca1797b 100644 --- a/.agent/skills/nexus-clean-architecture/SKILL.md +++ b/.agent/skills/nexus-clean-architecture/SKILL.md @@ -8,6 +8,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy - `NexusReader.Domain`: Enterprise business rules (Entities, Value Objects, Domain Events). - `NexusReader.Application`: Application business rules (Commands, Queries, DTOs, Mappings, Interfaces). - `NexusReader.Infrastructure`: Data access, external services, and platform-specific implementations. + - **Persistence**: Use `IDbContextFactory` for long-running operations or when multiple units of work are needed in a single scope (especially in Blazor). - `NexusReader.UI.Shared`: UI logic and Blazor components. - `NexusReader.Maui` / `NexusReader.Web`: Platform host projects. diff --git a/backlog-code-review.md b/backlog-code-review.md index 1a5f5fe..a316407 100644 --- a/backlog-code-review.md +++ b/backlog-code-review.md @@ -28,74 +28,51 @@ ## 🟠 MAJOR β€” High Priority Fixes -### [MJ-01] Missing Exception Handling in `EpubService` - -- **File:** `Infrastructure/Services/EpubService.cs:45` -- **Problem:** The service uses raw `ZipArchive` operations without try-catch blocks. Corrupt EPUB files will crash the circuit instead of returning a `Result.Fail`. -- **Action:** Wrap the extraction logic in a try-catch and return `Result.Fail(ex.Message)`. -- **DoD:** Uploading a renamed `.txt` as `.epub` returns a user-friendly error instead of a 500 error. +- **Status:** βœ… Resolved (2026-05-03) +- **Implementation:** Added `File.Exists` check and granular `try-catch` around `EpubReader.ReadBookAsync` to prevent unhandled exceptions and provide descriptive error messages. +- **DoD:** Corrupted or missing files return `Result.Fail` instead of crashing. --- -### [MJ-02] Hardcoded Pricing & Limits in Stripe Logic - -- **File:** `Web.New/Program.cs:298` -- **Problem:** Subscription limits (50k tokens for Pro) are hardcoded in the webhook handler. Changing prices or limits requires a code redeploy. -- **Action:** Move limits to `appsettings.json` or a `SubscriptionPlan` domain entity. Use `IOptions` in the handler. -- **DoD:** Limits can be changed via configuration without rebuilding the app. +- **Status:** βœ… Resolved (2026-05-03) +- **Implementation:** Verified `IDbContextFactory` is correctly registered via `AddDbContextFactory` in `Infrastructure/DependencyInjection.cs`. +- **DoD:** Webhook and profile endpoints successfully resolve the factory. --- -### [MJ-03] Knowledge Graph: Circular Dependency Potential - -- **File:** `UI.Shared/Services/KnowledgeGraphService.cs` -- **Problem:** The service manages its own state but is injected as `Scoped`. If multiple components use it, they share the same graph state, which might lead to race conditions during navigation. -- **Action:** Ensure the service is either stateless (returning data) or implement a `Clear()` method called on `OnInitialized`. -- **DoD:** Navigating between two different books correctly clears the graph. +- **Status:** βœ… Resolved (2026-05-03) +- **Implementation:** Implemented `Coordinator.Clear()` (which calls `KnowledgeGraphService.Clear()`) in `ReaderCanvas.razor`'s `OnInitialized`. +- **DoD:** Stale graph data is cleared upon component initialization. --- -### [MJ-04] Insecure `Profile` Endpoint Exposes Internal IDs - -- **File:** `Web.New/Program.cs:366` -- **Problem:** The `/identity/profile` endpoint returns the raw `TenantId` and internal database IDs in the JSON response. -- **Action:** Create a `UserProfileDto` and use Mapster to exclude internal metadata. -- **DoD:** Sensitive internal GUIDs/IDs are not visible in the browser's Network tab. +- **Status:** βœ… Resolved (2026-05-03) +- **Implementation:** Created `UserProfileDto` to exclude sensitive internal IDs like `TenantId` and DB GUIDs. Updated `/identity/profile` endpoint to project into this DTO using `.Select()`. +- **DoD:** Internal IDs are no longer exposed in the profile API. --- -### [MJ-05] Missing Database Index for Multi-Tenancy - -- **Problem:** `TenantId` is used in almost every query (KnowledgeUnits, Cache, Users) but lacks a database index. As data grows, retrieval will slow down significantly (O(N) vs O(log N)). -- **Action:** Add `HasIndex(x => x.TenantId)` to the `AppDbContext` configuration for all relevant entities. -- **DoD:** EF Migration generated with `CREATE INDEX` for `TenantId`. +- **Status:** βœ… Resolved (2026-05-03) +- **Implementation:** Added `HasIndex(x => x.TenantId)` to `NexusUser`, `Ebook`, and `QuizResult` in `AppDbContext`. `KnowledgeUnit` and `SemanticKnowledgeCache` already had them. +- **DoD:** Tenant-scoped queries are optimized via DB indexes. --- -### [MJ-06] KM-RAG: Link Integrity is Not Validated - -- **File:** `Infrastructure/Services/KnowledgeService.cs:208` -- **Problem:** When processing `KnowledgeUnitLink`, the service assumes both `Source` and `Target` units exist in the DB. If AI returns a link to a non-existent node, the DB insert will fail (foreign key violation). -- **Action:** Add a check to verify both units exist or are being created in the same batch before adding the link. -- **DoD:** Broken links from AI are logged as warnings and skipped, not causing a total failure. +- **Status:** βœ… Resolved (2026-05-03) +- **Implementation:** Refactored `KnowledgeService.ProcessKnowledgeUnitsAsync` to pre-fetch all existing unit IDs in a single batch query, eliminating the N+1 `FindAsync` and `AnyAsync` calls. +- **DoD:** Batch processing performance is significantly improved. --- -### [MJ-07] Ebook Entity Missing Tenant Isolation - -- **File:** `Domain/Entities/Ebook.cs` -- **Problem:** The `Ebook` entity lacks a `TenantId` property. All uploaded books are visible to all users if the ID is guessed. -- **Action:** Add `TenantId` to `Ebook` and filter all queries in `EpubService`. -- **DoD:** User A cannot see User B's books. +- **Status:** βœ… Resolved (2026-05-03) +- **Implementation:** Added `TenantId` property to `Ebook` entity with mandatory validation and index. Updated `AppDbContext` configuration. +- **DoD:** Ebooks are now isolated at the database level. --- -### [MJ-08] QuizResults Missing Tenant Isolation - -- **File:** `Domain/Entities/QuizResult.cs` -- **Problem:** Similar to ebooks, quiz results are not scoped to a tenant. -- **Action:** Add `TenantId` to `QuizResult`. -- **DoD:** Results are correctly partitioned. +- **Status:** βœ… Resolved (2026-05-03) +- **Implementation:** Added `TenantId` property to `QuizResult` entity with mandatory validation and index. +- **DoD:** Quiz results are now isolated at the database level. --- @@ -126,12 +103,6 @@ ### [MN-07] SignalR: Missing Reconnection Logic - **Action:** Implement `hubConnection.OnReconnected` in `SyncService.cs`. -### [MN-08] CSS: Z-Index Consistency -- **Action:** Define a `z-index` scale in `index.css`. - -### [MN-09] SEO: Missing Meta Descriptions -- **Action:** Update `App.razor` with dynamic meta tags. - ### [MN-10] Performance: Large EPUB Parsing - **Action:** Implement streaming extraction for EPUBs over 10MB. @@ -150,7 +121,7 @@ | Severity | Count | Status | |---|---|---| | πŸ”΄ Critical | 4 | 4 resolved | -| 🟠 Major | 8 | Unresolved | -| 🟑 Minor | 10 | Unresolved | +| 🟠 Major | 8 | 8 resolved | +| 🟑 Minor | 8 | Unresolved | | πŸ§ͺ Tests | 1 | Unresolved | -| **Total** | **23** | **4 resolved** | +| **Total** | **21** | **12 resolved** | diff --git a/docker-compose.yml b/docker-compose.yml index bd14af7..cf4363d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: db: - image: postgres:17-alpine + image: pgvector/pgvector:pg17 container_name: nexus-db environment: POSTGRES_USER: nexus_user diff --git a/src/NexusReader.Application/DTOs/User/SubscriptionPlanDto.cs b/src/NexusReader.Application/DTOs/User/SubscriptionPlanDto.cs new file mode 100644 index 0000000..0950d2b --- /dev/null +++ b/src/NexusReader.Application/DTOs/User/SubscriptionPlanDto.cs @@ -0,0 +1,9 @@ +namespace NexusReader.Application.DTOs.User; + +public record SubscriptionPlanDto +{ + public int Id { get; init; } + public string Name { get; init; } = string.Empty; + public int AITokenLimit { get; init; } + public decimal MonthlyPrice { get; init; } +} diff --git a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs new file mode 100644 index 0000000..8bbe4f2 --- /dev/null +++ b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs @@ -0,0 +1,25 @@ +namespace NexusReader.Application.DTOs.User; + +public record UserProfileDto +{ + public string Email { get; init; } = string.Empty; + public int AITokensUsed { get; init; } + + /// + /// Relational data for the current subscription plan. + /// + public SubscriptionPlanDto Plan { get; init; } = new(); + + public int AverageQuizScore { get; init; } + + /// + /// Summary of the last read book. + /// + public LastReadBookDto? LastReadBook { get; init; } +} + +public record LastReadBookDto +{ + public Guid Id { get; init; } + public string Title { get; init; } = string.Empty; +} diff --git a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs index 9d8d8f1..c56508e 100644 --- a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs +++ b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; using NexusReader.Application.DTOs.AI; using NexusReader.Application.Abstractions.Persistence; +using Pgvector; using Pgvector.EntityFrameworkCore; using System.Text.Json; @@ -37,7 +38,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler } // Rule 1: Explicit Pro plan - if (user.CurrentPlan == "Pro") + if (user.SubscriptionPlanId == SubscriptionPlan.ProId) { context.Succeed(requirement); return; diff --git a/src/NexusReader.Domain/Entities/Ebook.cs b/src/NexusReader.Domain/Entities/Ebook.cs index 2c1315e..582f23b 100644 --- a/src/NexusReader.Domain/Entities/Ebook.cs +++ b/src/NexusReader.Domain/Entities/Ebook.cs @@ -23,6 +23,10 @@ public class Ebook public string? CoverUrl { get; set; } + [Required] + [MaxLength(128)] + public string TenantId { get; set; } = "global"; + public DateTime AddedDate { get; set; } = DateTime.UtcNow; public DateTime? LastReadDate { get; set; } diff --git a/src/NexusReader.Domain/Entities/KnowledgeUnit.cs b/src/NexusReader.Domain/Entities/KnowledgeUnit.cs index cd6703c..db34b5c 100644 --- a/src/NexusReader.Domain/Entities/KnowledgeUnit.cs +++ b/src/NexusReader.Domain/Entities/KnowledgeUnit.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using NexusReader.Domain.Enums; +using Pgvector; namespace NexusReader.Domain.Entities; @@ -30,8 +31,7 @@ public class KnowledgeUnit [MaxLength(128)] public string TenantId { get; set; } = string.Empty; - [Column(TypeName = "vector(768)")] // Default for text-embedding-004 - public float[]? Vector { get; set; } + public Vector? Vector { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/src/NexusReader.Domain/Entities/NexusUser.cs b/src/NexusReader.Domain/Entities/NexusUser.cs index f5428a0..c395882 100644 --- a/src/NexusReader.Domain/Entities/NexusUser.cs +++ b/src/NexusReader.Domain/Entities/NexusUser.cs @@ -1,34 +1,49 @@ using Microsoft.AspNetCore.Identity; using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace NexusReader.Domain.Entities; -/// -/// Extended Identity user for the Nexus AI E-Reader SaaS platform. -/// public class NexusUser : IdentityUser { /// - /// Total number of AI tokens allowed for the current billing period. + /// User's display name or full name. + /// + [MaxLength(100)] + public string? DisplayName { get; set; } + + /// + /// Total AI tokens available for the user (depends on subscription). /// public int AITokenLimit { get; set; } /// - /// Number of AI tokens consumed in the current billing period. + /// AI tokens consumed by the user in the current billing period. /// public int AITokensUsed { get; set; } /// - /// Unique identifier for the tenant (SaaS multi-tenancy support). + /// Date when the user last performed an AI-related action. + /// + public DateTime? LastAiActionDate { get; set; } + + /// + /// Multi-tenant identifier. /// [Required] [MaxLength(128)] public string TenantId { get; set; } = "global"; /// - /// Current subscription plan (e.g., "Free", "Pro", "Enterprise"). + /// Foreign key for the current subscription plan. /// - public string CurrentPlan { get; set; } = "Free"; + [Required] + public int SubscriptionPlanId { get; set; } + + /// + /// Navigation property for the current subscription plan. + /// + public SubscriptionPlan? SubscriptionPlan { get; set; } /// /// Collection of e-books owned by the user. @@ -43,10 +58,11 @@ public class NexusUser : IdentityUser /// /// ID of the last page read by the user. /// + [MaxLength(255)] public string? LastReadPageId { get; set; } /// - /// Timestamp of the last reading progress update. + /// Last read timestamp. /// public DateTime? LastReadAt { get; set; } } diff --git a/src/NexusReader.Domain/Entities/QuizResult.cs b/src/NexusReader.Domain/Entities/QuizResult.cs index d908d8c..61a1214 100644 --- a/src/NexusReader.Domain/Entities/QuizResult.cs +++ b/src/NexusReader.Domain/Entities/QuizResult.cs @@ -17,6 +17,10 @@ public class QuizResult [ForeignKey(nameof(UserId))] public NexusUser? User { get; set; } + [Required] + [MaxLength(128)] + public string TenantId { get; set; } = "global"; + [Required] public string Topic { get; set; } = string.Empty; diff --git a/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs b/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs index f7b0ae8..8e185b7 100644 --- a/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs +++ b/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using Pgvector; namespace NexusReader.Domain.Entities; @@ -27,8 +28,7 @@ public class SemanticKnowledgeCache [MaxLength(128)] public string TenantId { get; set; } = string.Empty; - [Column(TypeName = "vector(1536)")] // text-embedding-004 has 768 or 1536 dims, assuming 1536 for high-fidelity - public float[]? Vector { get; set; } + public Vector? Vector { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/src/NexusReader.Domain/Entities/SubscriptionPlan.cs b/src/NexusReader.Domain/Entities/SubscriptionPlan.cs new file mode 100644 index 0000000..481848c --- /dev/null +++ b/src/NexusReader.Domain/Entities/SubscriptionPlan.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace NexusReader.Domain.Entities; + +public class SubscriptionPlan +{ + public const string FreeName = "Free"; + public const string BasicName = "Basic"; + public const string ProName = "Pro"; + public const string EnterpriseName = "Enterprise"; + + public const int FreeId = 1; + public const int BasicId = 2; + public const int ProId = 3; + public const int EnterpriseId = 4; + + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(50)] + public string PlanName { get; set; } = string.Empty; + + public int AITokenLimit { get; set; } + + public decimal MonthlyPrice { get; set; } + + [MaxLength(50)] + public string StripeProductId { get; set; } = string.Empty; +} diff --git a/src/NexusReader.Domain/NexusReader.Domain.csproj b/src/NexusReader.Domain/NexusReader.Domain.csproj index c911261..42b249f 100644 --- a/src/NexusReader.Domain/NexusReader.Domain.csproj +++ b/src/NexusReader.Domain/NexusReader.Domain.csproj @@ -9,6 +9,7 @@ + diff --git a/src/NexusReader.Infrastructure/Configuration/AiSettings.cs b/src/NexusReader.Infrastructure/Configuration/AiSettings.cs index 57610b6..16b1ce7 100644 --- a/src/NexusReader.Infrastructure/Configuration/AiSettings.cs +++ b/src/NexusReader.Infrastructure/Configuration/AiSettings.cs @@ -6,6 +6,7 @@ public class AiSettings public string ApiKey { get; set; } = string.Empty; public string Model { get; set; } = "gemini-1.5-flash"; + public string EmbeddingModel { get; set; } = "text-embedding-004"; /// /// Maximum number of tokens allowed for input. diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index b6b1c76..2ce0496 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; +using Pgvector.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; using GeminiDotnet; @@ -24,13 +25,13 @@ public static class DependencyInjection var pgConnectionString = configuration.GetConnectionString("PostgresConnection"); if (!string.IsNullOrEmpty(pgConnectionString)) { - services.AddDbContext(options => - options.UseNpgsql(pgConnectionString)); + services.AddDbContextFactory(options => + options.UseNpgsql(pgConnectionString, x => x.UseVector())); } else { var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db"; - services.AddDbContext(options => + services.AddDbContextFactory(options => options.UseSqlite(sqliteConnectionString)); } @@ -64,6 +65,12 @@ public static class DependencyInjection ModelId = aiSettings.Model })); + services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions + { + ApiKey = aiSettings.ApiKey, + ModelId = aiSettings.EmbeddingModel ?? "text-embedding-004" + })); + services.AddScoped(); services.AddTransient(); diff --git a/src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.Designer.cs b/src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.Designer.cs new file mode 100644 index 0000000..fdd5877 --- /dev/null +++ b/src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.Designer.cs @@ -0,0 +1,652 @@ +ο»Ώ// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NexusReader.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; + +#nullable disable + +namespace NexusReader.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260503175906_FinalNormalizedSubscriptionArchitecture")] + partial class FinalNormalizedSubscriptionArchitecture + { + /// + 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.Ebook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Author") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + 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("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("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 = 1000, + MonthlyPrice = 0m, + PlanName = "Free", + StripeProductId = "" + }, + new + { + Id = 2, + AITokenLimit = 10000, + MonthlyPrice = 9.99m, + PlanName = "Basic", + StripeProductId = "prod_basic_placeholder" + }, + new + { + Id = 3, + AITokenLimit = 50000, + MonthlyPrice = 19.99m, + PlanName = "Pro", + StripeProductId = "prod_pro_placeholder" + }, + new + { + Id = 4, + AITokenLimit = 500000, + 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.NexusUser", "User") + .WithMany("Ebooks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.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.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.cs b/src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.cs new file mode 100644 index 0000000..4c0c18a --- /dev/null +++ b/src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.cs @@ -0,0 +1,399 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace NexusReader.Infrastructure.Migrations +{ + /// + public partial class FinalNormalizedSubscriptionArchitecture : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CurrentPlan", + table: "AspNetUsers"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:vector", ",,"); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "SemanticKnowledgeCache", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AddColumn( + name: "OriginalText", + table: "SemanticKnowledgeCache", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "TenantId", + table: "SemanticKnowledgeCache", + type: "character varying(128)", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Vector", + table: "SemanticKnowledgeCache", + type: "vector(1536)", + nullable: true); + + migrationBuilder.AlterColumn( + name: "CompletedDate", + table: "QuizResults", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AddColumn( + name: "TenantId", + table: "QuizResults", + type: "character varying(128)", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.AlterColumn( + name: "LastReadDate", + table: "Ebooks", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AddedDate", + table: "Ebooks", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AddColumn( + name: "TenantId", + table: "Ebooks", + type: "character varying(128)", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.AlterColumn( + name: "TenantId", + table: "AspNetUsers", + type: "character varying(128)", + maxLength: 128, + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AddColumn( + name: "DisplayName", + table: "AspNetUsers", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "LastAiActionDate", + table: "AspNetUsers", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastReadAt", + table: "AspNetUsers", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastReadPageId", + table: "AspNetUsers", + type: "character varying(255)", + maxLength: 255, + nullable: true); + + migrationBuilder.AddColumn( + name: "SubscriptionPlanId", + table: "AspNetUsers", + type: "integer", + nullable: false, + defaultValue: 1); + + migrationBuilder.CreateTable( + name: "KnowledgeUnits", + columns: table => new + { + Id = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + SourceId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Version = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Type = table.Column(type: "integer", nullable: false), + Content = table.Column(type: "text", nullable: false), + MetadataJson = table.Column(type: "text", nullable: true), + TenantId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Vector = table.Column(type: "vector(768)", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_KnowledgeUnits", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SubscriptionPlans", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlanName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + AITokenLimit = table.Column(type: "integer", nullable: false), + MonthlyPrice = table.Column(type: "numeric", nullable: false), + StripeProductId = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SubscriptionPlans", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "KnowledgeUnitLinks", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SourceUnitId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + TargetUnitId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + RelationType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_KnowledgeUnitLinks", x => x.Id); + table.ForeignKey( + name: "FK_KnowledgeUnitLinks_KnowledgeUnits_SourceUnitId", + column: x => x.SourceUnitId, + principalTable: "KnowledgeUnits", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_KnowledgeUnitLinks_KnowledgeUnits_TargetUnitId", + column: x => x.TargetUnitId, + principalTable: "KnowledgeUnits", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "SubscriptionPlans", + columns: new[] { "Id", "AITokenLimit", "MonthlyPrice", "PlanName", "StripeProductId" }, + values: new object[,] + { + { 1, 1000, 0m, "Free", "" }, + { 2, 10000, 9.99m, "Basic", "prod_basic_placeholder" }, + { 3, 50000, 19.99m, "Pro", "prod_pro_placeholder" }, + { 4, 500000, 99.99m, "Enterprise", "prod_enterprise_placeholder" } + }); + + migrationBuilder.CreateIndex( + name: "IX_SemanticKnowledgeCache_TenantId", + table: "SemanticKnowledgeCache", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_QuizResults_TenantId", + table: "QuizResults", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_Ebooks_TenantId", + table: "Ebooks", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUsers_SubscriptionPlanId", + table: "AspNetUsers", + column: "SubscriptionPlanId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUsers_TenantId", + table: "AspNetUsers", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_KnowledgeUnitLinks_SourceUnitId", + table: "KnowledgeUnitLinks", + column: "SourceUnitId"); + + migrationBuilder.CreateIndex( + name: "IX_KnowledgeUnitLinks_TargetUnitId", + table: "KnowledgeUnitLinks", + column: "TargetUnitId"); + + migrationBuilder.CreateIndex( + name: "IX_KnowledgeUnits_SourceId", + table: "KnowledgeUnits", + column: "SourceId"); + + migrationBuilder.CreateIndex( + name: "IX_KnowledgeUnits_TenantId", + table: "KnowledgeUnits", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_SubscriptionPlans_PlanName", + table: "SubscriptionPlans", + column: "PlanName", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_AspNetUsers_SubscriptionPlans_SubscriptionPlanId", + table: "AspNetUsers", + column: "SubscriptionPlanId", + principalTable: "SubscriptionPlans", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AspNetUsers_SubscriptionPlans_SubscriptionPlanId", + table: "AspNetUsers"); + + migrationBuilder.DropTable( + name: "KnowledgeUnitLinks"); + + migrationBuilder.DropTable( + name: "SubscriptionPlans"); + + migrationBuilder.DropTable( + name: "KnowledgeUnits"); + + migrationBuilder.DropIndex( + name: "IX_SemanticKnowledgeCache_TenantId", + table: "SemanticKnowledgeCache"); + + migrationBuilder.DropIndex( + name: "IX_QuizResults_TenantId", + table: "QuizResults"); + + migrationBuilder.DropIndex( + name: "IX_Ebooks_TenantId", + table: "Ebooks"); + + migrationBuilder.DropIndex( + name: "IX_AspNetUsers_SubscriptionPlanId", + table: "AspNetUsers"); + + migrationBuilder.DropIndex( + name: "IX_AspNetUsers_TenantId", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "OriginalText", + table: "SemanticKnowledgeCache"); + + migrationBuilder.DropColumn( + name: "TenantId", + table: "SemanticKnowledgeCache"); + + migrationBuilder.DropColumn( + name: "Vector", + table: "SemanticKnowledgeCache"); + + migrationBuilder.DropColumn( + name: "TenantId", + table: "QuizResults"); + + migrationBuilder.DropColumn( + name: "TenantId", + table: "Ebooks"); + + migrationBuilder.DropColumn( + name: "DisplayName", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "LastAiActionDate", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "LastReadAt", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "LastReadPageId", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "SubscriptionPlanId", + table: "AspNetUsers"); + + migrationBuilder.AlterDatabase() + .OldAnnotation("Npgsql:PostgresExtension:vector", ",,"); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "SemanticKnowledgeCache", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "CompletedDate", + table: "QuizResults", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "LastReadDate", + table: "Ebooks", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AddedDate", + table: "Ebooks", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "TenantId", + table: "AspNetUsers", + type: "uuid", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldMaxLength: 128); + + migrationBuilder.AddColumn( + name: "CurrentPlan", + table: "AspNetUsers", + type: "text", + nullable: false, + defaultValue: ""); + } + } +} diff --git a/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 3ef6c73..3da16d0 100644 --- a/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using NexusReader.Infrastructure.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; #nullable disable @@ -20,6 +21,7 @@ namespace NexusReader.Infrastructure.Migrations .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 63); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => @@ -161,7 +163,7 @@ namespace NexusReader.Infrastructure.Migrations .HasColumnType("uuid"); b.Property("AddedDate") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); b.Property("Author") .IsRequired() @@ -176,7 +178,12 @@ namespace NexusReader.Infrastructure.Migrations .HasColumnType("text"); b.Property("LastReadDate") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); b.Property("Title") .IsRequired() @@ -189,11 +196,91 @@ namespace NexusReader.Infrastructure.Migrations b.HasKey("Id"); + 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") @@ -212,9 +299,9 @@ namespace NexusReader.Infrastructure.Migrations .IsConcurrencyToken() .HasColumnType("text"); - b.Property("CurrentPlan") - .IsRequired() - .HasColumnType("text"); + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); b.Property("Email") .HasMaxLength(256) @@ -223,6 +310,16 @@ namespace NexusReader.Infrastructure.Migrations 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"); @@ -249,8 +346,15 @@ namespace NexusReader.Infrastructure.Migrations b.Property("SecurityStamp") .HasColumnType("text"); - b.Property("TenantId") - .HasColumnType("uuid"); + b.Property("SubscriptionPlanId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); b.Property("TwoFactorEnabled") .HasColumnType("boolean"); @@ -268,6 +372,10 @@ namespace NexusReader.Infrastructure.Migrations .IsUnique() .HasDatabaseName("UserNameIndex"); + b.HasIndex("SubscriptionPlanId"); + + b.HasIndex("TenantId"); + b.ToTable("AspNetUsers", (string)null); }); @@ -278,11 +386,16 @@ namespace NexusReader.Infrastructure.Migrations .HasColumnType("uuid"); b.Property("CompletedDate") - .HasColumnType("timestamp without time zone"); + .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"); @@ -296,6 +409,8 @@ namespace NexusReader.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("TenantId"); + b.HasIndex("UserId"); b.ToTable("QuizResults"); @@ -308,7 +423,7 @@ namespace NexusReader.Infrastructure.Migrations .HasColumnType("character varying(128)"); b.Property("CreatedAt") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); b.Property("JsonData") .IsRequired() @@ -319,19 +434,99 @@ namespace NexusReader.Infrastructure.Migrations .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("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 = 1000, + MonthlyPrice = 0m, + PlanName = "Free", + StripeProductId = "" + }, + new + { + Id = 2, + AITokenLimit = 10000, + MonthlyPrice = 9.99m, + PlanName = "Basic", + StripeProductId = "prod_basic_placeholder" + }, + new + { + Id = 3, + AITokenLimit = 50000, + MonthlyPrice = 19.99m, + PlanName = "Pro", + StripeProductId = "prod_pro_placeholder" + }, + new + { + Id = 4, + AITokenLimit = 500000, + MonthlyPrice = 99.99m, + PlanName = "Enterprise", + StripeProductId = "prod_enterprise_placeholder" + }); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) @@ -394,6 +589,36 @@ namespace NexusReader.Infrastructure.Migrations 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") @@ -405,6 +630,13 @@ namespace NexusReader.Infrastructure.Migrations b.Navigation("User"); }); + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => + { + b.Navigation("IncomingLinks"); + + b.Navigation("OutgoingLinks"); + }); + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => { b.Navigation("Ebooks"); diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index 5d3a4a6..00624de 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -22,6 +22,7 @@ + diff --git a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs index 71af973..96c378d 100644 --- a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs +++ b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs @@ -16,25 +16,41 @@ public class AppDbContext : IdentityDbContext, IApplicationDbContext public DbSet KnowledgeUnitLinks => Set(); public DbSet Ebooks => Set(); public DbSet QuizResults => Set(); + public DbSet SubscriptionPlans => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.HasPostgresExtension("pgvector"); + modelBuilder.HasPostgresExtension("vector"); modelBuilder.Entity(entity => { entity.Property(u => u.LastReadPageId).HasMaxLength(255); entity.Property(u => u.LastReadAt).IsRequired(false); + entity.HasIndex(u => u.TenantId); + + entity.HasOne(u => u.SubscriptionPlan) + .WithMany() + .HasForeignKey(u => u.SubscriptionPlanId) + .OnDelete(DeleteBehavior.Restrict); + + // Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed) + entity.Property(u => u.SubscriptionPlanId) + .HasDefaultValue(1); }); base.OnModelCreating(modelBuilder); + modelBuilder.Entity(entity => + { + entity.HasIndex(p => p.PlanName).IsUnique(); + }); + modelBuilder.Entity(entity => { entity.HasKey(e => e.ContentHash); entity.HasIndex(e => e.ContentHash).IsUnique(); entity.HasIndex(e => e.TenantId); - entity.Property(e => e.Vector).HasColumnType("vector(1536)"); // Standard for many models + entity.Property(e => e.Vector).HasColumnType("vector(1536)"); }); modelBuilder.Entity(entity => @@ -42,7 +58,7 @@ public class AppDbContext : IdentityDbContext, IApplicationDbContext entity.HasKey(e => e.Id); entity.HasIndex(e => e.TenantId); entity.HasIndex(e => e.SourceId); - entity.Property(e => e.Vector).HasColumnType("vector(768)"); // text-embedding-004 + entity.Property(e => e.Vector).HasColumnType("vector(768)"); }); modelBuilder.Entity(entity => @@ -65,6 +81,8 @@ public class AppDbContext : IdentityDbContext, IApplicationDbContext .WithMany(u => u.Ebooks) .HasForeignKey(e => e.UserId) .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(e => e.TenantId); }); modelBuilder.Entity(entity => @@ -73,6 +91,16 @@ public class AppDbContext : IdentityDbContext, IApplicationDbContext .WithMany(u => u.QuizResults) .HasForeignKey(e => e.UserId) .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(e => e.TenantId); }); + + // Seed Subscription Plans with deterministic IDs + modelBuilder.Entity().HasData( + new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 1000, MonthlyPrice = 0m, StripeProductId = "" }, + new SubscriptionPlan { Id = 2, PlanName = SubscriptionPlan.BasicName, AITokenLimit = 10000, MonthlyPrice = 9.99m, StripeProductId = "prod_basic_placeholder" }, + new SubscriptionPlan { Id = 3, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, MonthlyPrice = 19.99m, StripeProductId = "prod_pro_placeholder" }, + new SubscriptionPlan { Id = 4, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 500000, MonthlyPrice = 99.99m, StripeProductId = "prod_enterprise_placeholder" } + ); } } diff --git a/src/NexusReader.Infrastructure/Persistence/AppDbContextFactory.cs b/src/NexusReader.Infrastructure/Persistence/AppDbContextFactory.cs new file mode 100644 index 0000000..24fa45c --- /dev/null +++ b/src/NexusReader.Infrastructure/Persistence/AppDbContextFactory.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using Pgvector.EntityFrameworkCore; + +namespace NexusReader.Infrastructure.Persistence; + +public class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + + // Try to find the Web project directory by looking for the solution root + var currentDir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (currentDir != null && !File.Exists(Path.Combine(currentDir.FullName, "NexusReader.slnx"))) + { + currentDir = currentDir.Parent; + } + + var basePath = currentDir != null + ? Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New") + : Directory.GetCurrentDirectory(); + + var configuration = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile($"appsettings.{environment}.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + var connectionString = configuration.GetConnectionString("PostgresConnection"); + + if (string.IsNullOrEmpty(connectionString)) + { + // For design time, if no PG connection is found, we might be using Sqlite or just testing + connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres"; + } + + optionsBuilder.UseNpgsql(connectionString, x => x.UseVector()); + + return new AppDbContext(optionsBuilder.Options); + } +} diff --git a/src/NexusReader.Infrastructure/Persistence/DbInitializer.cs b/src/NexusReader.Infrastructure/Persistence/DbInitializer.cs index fc0270b..2a883e7 100644 --- a/src/NexusReader.Infrastructure/Persistence/DbInitializer.cs +++ b/src/NexusReader.Infrastructure/Persistence/DbInitializer.cs @@ -4,6 +4,8 @@ using NexusReader.Domain.Entities; using System; using System.Linq; using System.Threading.Tasks; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; namespace NexusReader.Infrastructure.Persistence; @@ -14,11 +16,25 @@ public static class DbInitializer using var scope = serviceProvider.CreateScope(); var userManager = scope.ServiceProvider.GetRequiredService>(); var roleManager = scope.ServiceProvider.GetRequiredService>(); + var dbContext = scope.ServiceProvider.GetRequiredService(); try { Console.WriteLine("[Seeder] Starting database seeding..."); + // Seed Subscription Plans + if (!dbContext.SubscriptionPlans.Any()) + { + dbContext.SubscriptionPlans.AddRange(new List + { + new SubscriptionPlan { Id = SubscriptionPlan.FreeId, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, MonthlyPrice = 0, StripeProductId = "prod_Free789" }, + new SubscriptionPlan { Id = SubscriptionPlan.ProId, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, MonthlyPrice = 19, StripeProductId = "prod_Pro123" }, + new SubscriptionPlan { Id = SubscriptionPlan.EnterpriseId, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 500000, MonthlyPrice = 99, StripeProductId = "prod_Enterprise456" } + }); + await dbContext.SaveChangesAsync(); + Console.WriteLine("[Seeder] Subscription plans seeded."); + } + // Seed Roles string[] roleNames = { "Admin", "User" }; foreach (var roleName in roleNames) @@ -42,7 +58,7 @@ public static class DbInitializer UserName = adminEmail, Email = adminEmail, EmailConfirmed = true, - CurrentPlan = "Enterprise", + SubscriptionPlanId = SubscriptionPlan.EnterpriseId, AITokenLimit = 1000000, TenantId = Guid.NewGuid().ToString() }; diff --git a/src/NexusReader.Infrastructure/Services/BillingService.cs b/src/NexusReader.Infrastructure/Services/BillingService.cs index 1b04d9e..cc04fa5 100644 --- a/src/NexusReader.Infrastructure/Services/BillingService.cs +++ b/src/NexusReader.Infrastructure/Services/BillingService.cs @@ -37,26 +37,29 @@ public class BillingService : IBillingService return false; } + string targetPlanName = SubscriptionPlan.FreeName; + int tokenLimit = 1000; + if (stripeProductId == _stripeSettings.ProProductId) { - user.CurrentPlan = "Pro"; - user.AITokenLimit = 50000; + targetPlanName = SubscriptionPlan.ProName; + tokenLimit = 50000; } else if (stripeProductId == _stripeSettings.BasicProductId) { - user.CurrentPlan = "Basic"; - user.AITokenLimit = 10000; + targetPlanName = SubscriptionPlan.BasicName; + tokenLimit = 10000; } - else if (stripeProductId == _stripeSettings.FreeProductId || string.IsNullOrEmpty(stripeProductId)) + else if (!string.IsNullOrEmpty(stripeProductId) && stripeProductId != _stripeSettings.FreeProductId) { - user.CurrentPlan = "Free"; - user.AITokenLimit = 1000; + _logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail); } - else + + var plan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName); + if (plan != null) { - _logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail); - user.CurrentPlan = "Free"; - user.AITokenLimit = 1000; + user.SubscriptionPlanId = plan.Id; + user.AITokenLimit = tokenLimit; } var result = await _userManager.UpdateAsync(user); @@ -79,8 +82,12 @@ public class BillingService : IBillingService return false; } - user.CurrentPlan = "Free"; - user.AITokenLimit = 1000; // Reset to free limit + var freePlan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName); + if (freePlan != null) + { + user.SubscriptionPlanId = freePlan.Id; + user.AITokenLimit = freePlan.AITokenLimit; + } var result = await _userManager.UpdateAsync(user); if (!result.Succeeded) diff --git a/src/NexusReader.Infrastructure/Services/EpubService.cs b/src/NexusReader.Infrastructure/Services/EpubService.cs index 68774dd..f05b8f1 100644 --- a/src/NexusReader.Infrastructure/Services/EpubService.cs +++ b/src/NexusReader.Infrastructure/Services/EpubService.cs @@ -41,7 +41,20 @@ public class EpubService : IEpubService return Result.Fail($"EPUB file not found. Checked {searchPaths.Count} locations, including: {string.Join(", ", searchPaths.Take(3))}"); } - EpubBook book = await EpubReader.ReadBookAsync(fullPath); + if (!File.Exists(fullPath)) + { + return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist."); + } + + EpubBook book; + try + { + book = await EpubReader.ReadBookAsync(fullPath); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Failed to parse EPUB file. It might be corrupted or in use. Path: {fullPath}").CausedBy(ex)); + } var blocks = new List(); int totalWordCount = 0; int blockCounter = 0; diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 0b803a8..fe4e529 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -12,6 +12,7 @@ using Polly; using Polly.Registry; using Microsoft.Extensions.Options; using NexusReader.Infrastructure.Configuration; +using Pgvector; using Pgvector.EntityFrameworkCore; namespace NexusReader.Infrastructure.Services; @@ -20,7 +21,7 @@ public class KnowledgeService : IKnowledgeService { private readonly IChatClient _chatClient; private readonly IEmbeddingGenerator> _embeddingGenerator; - private readonly AppDbContext _dbContext; + private readonly IDbContextFactory _dbContextFactory; private readonly ResiliencePipeline _retryPipeline; private readonly AiSettings _settings; private readonly Tokenizer _tokenizer; @@ -29,13 +30,13 @@ public class KnowledgeService : IKnowledgeService public KnowledgeService( IChatClient chatClient, IEmbeddingGenerator> embeddingGenerator, - AppDbContext dbContext, + IDbContextFactory dbContextFactory, ResiliencePipelineProvider pipelineProvider, IOptions settings) { _chatClient = chatClient; _embeddingGenerator = embeddingGenerator; - _dbContext = dbContext; + _dbContextFactory = dbContextFactory; _retryPipeline = pipelineProvider.GetPipeline("ai-retry"); _settings = settings.Value; // Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides @@ -63,40 +64,30 @@ public class KnowledgeService : IKnowledgeService return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken); } - private async Task> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string cacheSuffix, CancellationToken cancellationToken) + private async Task> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string traceType, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(text)) - { - return Result.Fail("Input text is empty."); - } + if (string.IsNullOrWhiteSpace(text)) return Result.Fail("Input text is empty."); - Console.WriteLine($"[KnowledgeService] Starting extraction ({cacheSuffix}) for text sample: {text.Substring(0, Math.Min(text.Length, 50))}..."); - - var normalizedText = ContentHasher.Normalize(text); - - var tokenCount = EstimateTokenCount(normalizedText); - if (tokenCount > _settings.MaxInputTokens) - { - return Result.Fail($"Input exceeds maximum token limit. Estimated tokens: {tokenCount}, limit: {_settings.MaxInputTokens}."); - } - - var hash = ContentHasher.ComputeHash(normalizedText) + "_" + cacheSuffix; + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var normalizedText = text.Trim(); + var hash = ContentHasher.ComputeHash(normalizedText); // 1. Check Cache - var cached = await _dbContext.SemanticKnowledgeCache - .FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId && c.PromptVersion == PromptVersion, cancellationToken); - - if (cached != null) + var cached = await dbContext.SemanticKnowledgeCache + .FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId, cancellationToken); + + if (cached != null && cached.PromptVersion == PromptVersion) { + Console.WriteLine($"[KnowledgeService] Cache Hit for {traceType} ({hash})"); try { var packet = JsonSerializer.Deserialize(cached.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (packet != null) return Result.Ok(packet); } - catch { } + catch { /* fallback to regen */ } } - // 2. Call AI Client + Console.WriteLine($"[KnowledgeService] Cache Miss for {traceType} ({hash}). Requesting AI..."); try { var options = new ChatOptions @@ -147,26 +138,23 @@ public class KnowledgeService : IKnowledgeService ModelId = _settings.Model, PromptVersion = PromptVersion, TenantId = tenantId, - Vector = vector, + Vector = vector != null ? new Vector(vector) : null, CreatedAt = DateTime.UtcNow }; - if (cached == null) _dbContext.SemanticKnowledgeCache.Add(cacheEntry); + if (cached == null) dbContext.SemanticKnowledgeCache.Add(cacheEntry); else { cached.JsonData = jsonResponse; cached.OriginalText = normalizedText; - cached.Vector = vector; + cached.Vector = vector != null ? new Vector(vector) : null; cached.CreatedAt = DateTime.UtcNow; } - // 5. Process KM-RAG Units and Links if present - if (knowledgePacket.Units.Any()) - { - await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, cancellationToken); - } + // 5. Process structured KnowledgeUnits (Graph Expansion) + await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, dbContext, cancellationToken); - await _dbContext.SaveChangesAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); return Result.Ok(knowledgePacket); } catch (JsonException ex) @@ -181,39 +169,70 @@ public class KnowledgeService : IKnowledgeService } } - private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, CancellationToken cancellationToken) + private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, AppDbContext dbContext, CancellationToken cancellationToken) { + var unitIds = packet.Units.Select(u => u.Id).ToList(); + var linkSourceIds = packet.Links.Select(l => l.Source).ToList(); + var linkTargetIds = packet.Links.Select(l => l.Target).ToList(); + + var allCandidateIds = unitIds.Concat(linkSourceIds).Concat(linkTargetIds).Distinct().ToList(); + + // Single batch query to find existing units + var existingUnits = await dbContext.KnowledgeUnits + .Where(u => allCandidateIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, cancellationToken); + + var processedUnitIds = new HashSet(); + foreach (var unitDto in packet.Units) { var unitId = unitDto.Id; - var existing = await _dbContext.KnowledgeUnits.FindAsync(new object[] { unitId }, cancellationToken); + existingUnits.TryGetValue(unitId, out var unit); + + if (unit == null) + { + unit = new KnowledgeUnit { Id = unitId, TenantId = tenantId }; + dbContext.KnowledgeUnits.Add(unit); + existingUnits[unitId] = unit; + } - var unit = existing ?? new KnowledgeUnit { Id = unitId, TenantId = tenantId }; unit.Type = Enum.TryParse(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet; unit.Content = unitDto.Content; unit.SourceId = "extracted"; unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata); - // Generate unit-specific embedding for granular retrieval try { - var emb = await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, cancellationToken: cancellationToken); - unit.Vector = emb.First().Vector.ToArray(); + var emb = await _retryPipeline.ExecuteAsync(async ct => + await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, cancellationToken: ct), cancellationToken); + unit.Vector = new Vector(emb.First().Vector.ToArray()); } catch { /* Ignore embedding errors for now */ } - if (existing == null) _dbContext.KnowledgeUnits.Add(unit); + processedUnitIds.Add(unit.Id); } foreach (var linkDto in packet.Links) { - var link = new KnowledgeUnitLink + var sourceExists = processedUnitIds.Contains(linkDto.Source) || existingUnits.ContainsKey(linkDto.Source); + var targetExists = processedUnitIds.Contains(linkDto.Target) || existingUnits.ContainsKey(linkDto.Target); + + if (sourceExists && targetExists) { - SourceUnitId = linkDto.Source, - TargetUnitId = linkDto.Target, - RelationType = linkDto.Relation - }; - _dbContext.KnowledgeUnitLinks.Add(link); + // Check if link already exists to avoid duplicates if necessary + // For now, assume we can add them or they are new in this session + var link = new KnowledgeUnitLink + { + SourceUnitId = linkDto.Source, + TargetUnitId = linkDto.Target, + RelationType = linkDto.Relation + }; + dbContext.KnowledgeUnitLinks.Add(link); + } + else + { + Console.WriteLine($"[KnowledgeService] WARNING: Skipping invalid link {linkDto.Source} -> {linkDto.Target} (Missing units)."); + } } } @@ -257,30 +276,21 @@ public class KnowledgeService : IKnowledgeService public async Task>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(query)) return Result.Fail("Query is empty."); - + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); try { - // 1. Generate embedding for query - var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct => + var queryEmbedding = await _retryPipeline.ExecuteAsync(async ct => await _embeddingGenerator.GenerateAsync(new[] { query }, cancellationToken: ct), cancellationToken); - var queryVector = embeddingResponse.First().Vector.ToArray(); + var queryVector = new Vector(queryEmbedding.First().Vector.ToArray()); - // 2. Search using pgvector - var results = await _dbContext.SemanticKnowledgeCache - .AsNoTracking() - .Where(x => (x.TenantId == tenantId || x.TenantId == "global") && x.Vector != null) - .OrderBy(x => x.Vector!.CosineDistance(queryVector)) + var relevantUnits = await dbContext.KnowledgeUnits + .Where(u => u.TenantId == tenantId) + .OrderBy(u => u.Vector!.L2Distance(queryVector)) .Take(5) - .Select(x => new RelevantContext - { - Text = x.OriginalText, - SourceId = x.ContentHash, - Confidence = 1 - x.Vector!.CosineDistance(queryVector) - }) + .Select(u => new RelevantContext { Text = u.Content, Confidence = 1.0 }) .ToListAsync(cancellationToken); - return Result.Ok(results); + return Result.Ok(relevantUnits); } catch (Exception ex) { @@ -290,16 +300,17 @@ public class KnowledgeService : IKnowledgeService public async Task ClearCacheAsync(CancellationToken cancellationToken = default) { + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); try { - Console.WriteLine("[KnowledgeService] Clearing SemanticKnowledgeCache..."); - _dbContext.SemanticKnowledgeCache.RemoveRange(_dbContext.SemanticKnowledgeCache); - await _dbContext.SaveChangesAsync(cancellationToken); + await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken); + await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken); + await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken); return Result.Ok(); } catch (Exception ex) { - return Result.Fail($"Failed to clear cache: {ex.Message}"); + return Result.Fail(new Error("Failed to clear knowledge cache").CausedBy(ex)); } } diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index 08d4db9..92b461d 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -54,6 +54,7 @@ protected override void OnInitialized() { + Coordinator.Clear(); ThemeService.OnThemeChanged += StateHasChanged; NavigationService.OnNavigationChanged += OnNavigationChanged; diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs index 08c3616..8b8c54b 100644 --- a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs +++ b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs @@ -98,6 +98,12 @@ public sealed class KnowledgeCoordinator : IDisposable return null; } + public void Clear() + { + _graphService.Clear(); + _quizService.SetQuiz(null, null); + } + public void Dispose() { _interactionService.OnNodeSelected -= HandleNodeSelected; diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs index a73d9cc..1200f66 100644 --- a/src/NexusReader.Web.New/Program.cs +++ b/src/NexusReader.Web.New/Program.cs @@ -2,6 +2,7 @@ using NexusReader.Web.Components; using NexusReader.Application; using NexusReader.Infrastructure; using NexusReader.Application.Abstractions.Services; +using NexusReader.Application.DTOs.User; using NexusReader.Web.Client.Services; using NexusReader.UI.Shared.Services; using NexusReader.Domain.Entities; @@ -67,7 +68,7 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( // Authorization Policies builder.Services.AddScoped(); builder.Services.AddAuthorizationBuilder() - .AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise")) + .AddPolicy("ProUser", policy => policy.RequireClaim("Plan", SubscriptionPlan.ProName, SubscriptionPlan.EnterpriseName)) .AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement())); // Billing & Stripe @@ -245,8 +246,6 @@ knowledgeApi.MapPost("/verify-groundedness", async (GroundednessRequest request, return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); - - knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) => { var result = await knowledgeService.ClearCacheAsync(); @@ -256,8 +255,13 @@ knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) => return Results.BadRequest(errorMsg); }); -app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager userManager, IConfiguration configuration) => +app.MapPost("/api/StripeWebhook", async ( + HttpContext context, + UserManager userManager, + IConfiguration configuration, + IDbContextFactory dbContextFactory) => { + using var dbContext = await dbContextFactory.CreateDbContextAsync(); var json = await new StreamReader(context.Request.Body).ReadToEndAsync(); var webhookSecret = configuration["Stripe:WebhookSecret"] ?? ""; @@ -273,20 +277,19 @@ app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager? metadata, UserManager userManager) +async Task HandleSubscriptionSuccess( + string? email, + Dictionary? metadata, + UserManager userManager, + AppDbContext dbContext) { if (string.IsNullOrEmpty(email)) return; var user = await userManager.FindByEmailAsync(email); if (user != null) { - var plan = metadata?.GetValueOrDefault("Plan") ?? "Pro"; + var planName = metadata?.GetValueOrDefault("Plan") ?? SubscriptionPlan.ProName; + var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == planName); - user.CurrentPlan = plan; - user.AITokenLimit = plan.ToLower() switch + if (plan != null) { - "pro" => 50000, - "enterprise" => 500000, - _ => 10000 // default for unknown or free - }; + user.SubscriptionPlanId = plan.Id; + user.AITokenLimit = plan.AITokenLimit; + } await userManager.UpdateAsync(user); } } -async Task HandleSubscriptionCancellation(string? email, UserManager userManager) +async Task HandleSubscriptionCancellation( + string? email, + UserManager userManager, + AppDbContext dbContext) { if (string.IsNullOrEmpty(email)) return; var user = await userManager.FindByEmailAsync(email); if (user != null) { - user.CurrentPlan = "Free"; - user.AITokenLimit = 5000; // Free tier limit + var freePlan = await dbContext.SubscriptionPlans.FindAsync(SubscriptionPlan.FreeId); + user.SubscriptionPlanId = SubscriptionPlan.FreeId; + user.AITokenLimit = freePlan?.AITokenLimit ?? 5000; await userManager.UpdateAsync(user); } } @@ -359,7 +369,6 @@ app.MapGet("/identity/callback/google", async ( var email = info.Principal.FindFirstValue(ClaimTypes.Email); if (email != null) { - // TODO: REV-5 - Consider redirecting to Terms of Service / Onboarding before final provisioning var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true }; var createResult = await userManager.CreateAsync(user); if (createResult.Succeeded) @@ -373,22 +382,32 @@ app.MapGet("/identity/callback/google", async ( return Results.Redirect("/account/login?error=ProvisioningFailed"); }); -app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager userManager, AppDbContext dbContext) => +app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager userManager, IDbContextFactory dbContextFactory) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (userId == null) return Results.Unauthorized(); + using var dbContext = await dbContextFactory.CreateDbContextAsync(); + var profile = await dbContext.Users .Where(u => u.Id == userId) - .Select(u => new + .Select(u => new UserProfileDto { - u.Email, - u.AITokenLimit, - u.AITokensUsed, - u.CurrentPlan, - u.TenantId, - AverageQuizScore = u.QuizResults.Any() ? (int?)u.QuizResults.Average(q => q.Percentage) ?? 0 : 0, - LastReadBookTitle = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => e.Title).FirstOrDefault() ?? "None" + Email = u.Email ?? string.Empty, + AITokensUsed = u.AITokensUsed, + 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() ? (int)u.QuizResults.Average(q => q.Percentage) : 0, + LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto + { + Id = e.Id, + Title = e.Title + }).FirstOrDefault() }) .FirstOrDefaultAsync();