From 49b232eaa842f1e1efa76f0cfe91b13b147e34e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 5 May 2026 20:21:57 +0200 Subject: [PATCH 1/2] fix: migrate to IDbContextFactory and remove direct AppDbContext from DI --- .../Identity/TokenLimitHandler.cs | 7 ++++--- src/NexusReader.Web.New/Program.cs | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/NexusReader.Infrastructure/Identity/TokenLimitHandler.cs b/src/NexusReader.Infrastructure/Identity/TokenLimitHandler.cs index cdb5278..900c378 100644 --- a/src/NexusReader.Infrastructure/Identity/TokenLimitHandler.cs +++ b/src/NexusReader.Infrastructure/Identity/TokenLimitHandler.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using NexusReader.Domain.Entities; using NexusReader.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; namespace NexusReader.Infrastructure.Identity; @@ -11,12 +12,12 @@ namespace NexusReader.Infrastructure.Identity; /// public class TokenLimitHandler : AuthorizationHandler { - private readonly AppDbContext _dbContext; + private readonly IDbContextFactory _dbContextFactory; private readonly UserManager _userManager; - public TokenLimitHandler(AppDbContext dbContext, UserManager userManager) + public TokenLimitHandler(IDbContextFactory dbContextFactory, UserManager userManager) { - _dbContext = dbContext; + _dbContextFactory = dbContextFactory; _userManager = userManager; } diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs index 1200f66..997c273 100644 --- a/src/NexusReader.Web.New/Program.cs +++ b/src/NexusReader.Web.New/Program.cs @@ -135,7 +135,8 @@ using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; var logger = services.GetRequiredService>(); - var dbContext = services.GetRequiredService(); + var dbContextFactory = services.GetRequiredService>(); + using var dbContext = await dbContextFactory.CreateDbContextAsync(); int maxRetries = 5; int delayMs = 2000; -- 2.52.0 From 8ee7b512d28cbd783b76406d7a25f5cc208857cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Thu, 7 May 2026 18:29:43 +0200 Subject: [PATCH 2/2] feat: handle opaque tokens and add remember me checkbox --- NexusReader.slnx | 1 + scratch/check_db.cs | 44 -- scratch/test_normalization.cs | 25 - .../Persistence/IApplicationDbContext.cs | 15 - .../DTOs/User/SubscriptionPlanDto.cs | 1 + .../NexusReader.Application.csproj | 3 +- .../Library/SearchLibrarySemanticallyQuery.cs | 20 +- .../Security/Authorization/ProUserHandler.cs | 21 +- ...20260428184727_InitialPostgres.Designer.cs | 4 +- .../20260428184727_InitialPostgres.cs | 2 +- ...60428185239_IncreaseHashLength.Designer.cs | 4 +- .../20260428185239_IncreaseHashLength.cs | 2 +- .../20260429080302_AddQuizResults.Designer.cs | 4 +- .../20260429080302_AddQuizResults.cs | 2 +- ...alizedSubscriptionArchitecture.Designer.cs | 4 +- ...FinalNormalizedSubscriptionArchitecture.cs | 2 +- ...scriptionPlanIsUnlimitedTokens.Designer.cs | 659 ++++++++++++++++++ ...UpdateSubscriptionPlanIsUnlimitedTokens.cs | 71 ++ .../Migrations/AppDbContextModelSnapshot.cs | 29 +- src/NexusReader.Data/NexusReader.Data.csproj | 27 + .../Persistence/AppDbContext.cs | 25 +- .../Persistence/AppDbContextFactory.cs | 2 +- .../Persistence/DbInitializer.cs | 48 +- .../Entities/SubscriptionPlan.cs | 2 + .../DependencyInjection.cs | 3 +- .../UpdateReadingProgressCommandHandler.cs | 13 +- .../Identity/TokenLimitHandler.cs | 12 +- .../NexusReader.Infrastructure.csproj | 1 + .../Services/BillingService.cs | 14 +- .../Services/KnowledgeService.cs | 2 +- .../Pages/Account/Login.razor | 11 +- .../Services/IdentityService.cs | 33 +- .../NexusAuthenticationStateProvider.cs | 58 +- .../wwwroot/css/nexus-auth.css | 22 + src/NexusReader.Web.New/Program.cs | 4 +- 35 files changed, 978 insertions(+), 212 deletions(-) delete mode 100644 scratch/check_db.cs delete mode 100644 scratch/test_normalization.cs delete mode 100644 src/NexusReader.Application/Abstractions/Persistence/IApplicationDbContext.cs rename src/{NexusReader.Infrastructure => NexusReader.Data}/Migrations/20260428184727_InitialPostgres.Designer.cs (99%) rename src/{NexusReader.Infrastructure => NexusReader.Data}/Migrations/20260428184727_InitialPostgres.cs (99%) rename src/{NexusReader.Infrastructure => NexusReader.Data}/Migrations/20260428185239_IncreaseHashLength.Designer.cs (99%) rename src/{NexusReader.Infrastructure => NexusReader.Data}/Migrations/20260428185239_IncreaseHashLength.cs (98%) rename src/{NexusReader.Infrastructure => NexusReader.Data}/Migrations/20260429080302_AddQuizResults.Designer.cs (99%) rename src/{NexusReader.Infrastructure => NexusReader.Data}/Migrations/20260429080302_AddQuizResults.cs (97%) rename src/{NexusReader.Infrastructure => NexusReader.Data}/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.Designer.cs (99%) rename src/{NexusReader.Infrastructure => NexusReader.Data}/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.cs (99%) create mode 100644 src/NexusReader.Data/Migrations/20260506184227_UpdateSubscriptionPlanIsUnlimitedTokens.Designer.cs create mode 100644 src/NexusReader.Data/Migrations/20260506184227_UpdateSubscriptionPlanIsUnlimitedTokens.cs rename src/{NexusReader.Infrastructure => NexusReader.Data}/Migrations/AppDbContextModelSnapshot.cs (96%) create mode 100644 src/NexusReader.Data/NexusReader.Data.csproj rename src/{NexusReader.Infrastructure => NexusReader.Data}/Persistence/AppDbContext.cs (78%) rename src/{NexusReader.Infrastructure => NexusReader.Data}/Persistence/AppDbContextFactory.cs (97%) rename src/{NexusReader.Infrastructure => NexusReader.Data}/Persistence/DbInitializer.cs (56%) diff --git a/NexusReader.slnx b/NexusReader.slnx index 07d109b..9fcc85b 100644 --- a/NexusReader.slnx +++ b/NexusReader.slnx @@ -6,6 +6,7 @@ + diff --git a/scratch/check_db.cs b/scratch/check_db.cs deleted file mode 100644 index f4fa88f..0000000 --- a/scratch/check_db.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.EntityFrameworkCore; -using NexusReader.Infrastructure.Persistence; -using Microsoft.Extensions.Configuration; -using NexusReader.Domain.Entities; -using System; -using System.Linq; -using System.Threading.Tasks; - -var configuration = new ConfigurationBuilder() - .AddJsonFile("src/NexusReader.Web.New/appsettings.json") - .Build(); - -var services = new ServiceCollection(); -var pgConnectionString = configuration.GetConnectionString("PostgresConnection"); -if (!string.IsNullOrEmpty(pgConnectionString)) -{ - services.AddDbContext(options => options.UseNpgsql(pgConnectionString)); -} -else -{ - services.AddDbContext(options => options.UseSqlite(configuration.GetConnectionString("SqliteConnection"))); -} - -var serviceProvider = services.BuildServiceProvider(); -using var scope = serviceProvider.CreateScope(); -var dbContext = scope.ServiceProvider.GetRequiredService(); - -try -{ - var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Email == "admin@nexus.com"); - if (user == null) - { - Console.WriteLine("User admin@nexus.com NOT FOUND in database."); - } - else - { - Console.WriteLine($"User found: {user.Email}, Id: {user.Id}, EmailConfirmed: {user.EmailConfirmed}"); - } -} -catch (Exception ex) -{ - Console.WriteLine($"Error accessing database: {ex.Message}"); -} diff --git a/scratch/test_normalization.cs b/scratch/test_normalization.cs deleted file mode 100644 index 7036985..0000000 --- a/scratch/test_normalization.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Text.RegularExpressions; - -public class Program -{ - public static void Main() - { - string input1 = "Hello \n World"; - string input2 = "Hello World"; - - string norm1 = Normalize(input1); - string norm2 = Normalize(input2); - - Console.WriteLine($"Input 1: '{input1}' -> Normalized: '{norm1}'"); - Console.WriteLine($"Input 2: '{input2}' -> Normalized: '{norm2}'"); - Console.WriteLine($"Match: {norm1 == norm2}"); - } - - public static string Normalize(string input) - { - if (string.IsNullOrWhiteSpace(input)) return string.Empty; - var normalized = Regex.Replace(input.Trim(), @"\s+", " "); - return normalized.ToLowerInvariant(); - } -} diff --git a/src/NexusReader.Application/Abstractions/Persistence/IApplicationDbContext.cs b/src/NexusReader.Application/Abstractions/Persistence/IApplicationDbContext.cs deleted file mode 100644 index 92c2ccb..0000000 --- a/src/NexusReader.Application/Abstractions/Persistence/IApplicationDbContext.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using NexusReader.Domain.Entities; - -namespace NexusReader.Application.Abstractions.Persistence; - -public interface IApplicationDbContext -{ - DbSet SemanticKnowledgeCache { get; } - DbSet KnowledgeUnits { get; } - DbSet KnowledgeUnitLinks { get; } - DbSet Ebooks { get; } - DbSet QuizResults { get; } - - Task SaveChangesAsync(CancellationToken cancellationToken = default); -} diff --git a/src/NexusReader.Application/DTOs/User/SubscriptionPlanDto.cs b/src/NexusReader.Application/DTOs/User/SubscriptionPlanDto.cs index 0950d2b..cfbf917 100644 --- a/src/NexusReader.Application/DTOs/User/SubscriptionPlanDto.cs +++ b/src/NexusReader.Application/DTOs/User/SubscriptionPlanDto.cs @@ -5,5 +5,6 @@ public record SubscriptionPlanDto public int Id { get; init; } public string Name { get; init; } = string.Empty; public int AITokenLimit { get; init; } + public bool IsUnlimitedTokens { get; init; } public decimal MonthlyPrice { get; init; } } diff --git a/src/NexusReader.Application/NexusReader.Application.csproj b/src/NexusReader.Application/NexusReader.Application.csproj index f375251..3963018 100644 --- a/src/NexusReader.Application/NexusReader.Application.csproj +++ b/src/NexusReader.Application/NexusReader.Application.csproj @@ -2,6 +2,7 @@ + @@ -13,7 +14,7 @@ - + diff --git a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs index c56508e..c5781bc 100644 --- a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs +++ b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs @@ -4,7 +4,8 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; using NexusReader.Application.DTOs.AI; -using NexusReader.Application.Abstractions.Persistence; + +using NexusReader.Data.Persistence; using Pgvector; using Pgvector.EntityFrameworkCore; using System.Text.Json; @@ -16,14 +17,14 @@ public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId, public class SearchLibrarySemanticallyQueryHandler : IRequestHandler>> { - private readonly IApplicationDbContext _dbContext; + private readonly IDbContextFactory _dbContextFactory; private readonly IEmbeddingGenerator> _embeddingGenerator; - + public SearchLibrarySemanticallyQueryHandler( - IApplicationDbContext dbContext, + IDbContextFactory dbContextFactory, IEmbeddingGenerator> embeddingGenerator) { - _dbContext = dbContext; + _dbContextFactory = dbContextFactory; _embeddingGenerator = embeddingGenerator; } @@ -34,6 +35,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null) .OrderBy(x => x.Vector!.CosineDistance(queryVector)) @@ -51,7 +53,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler x.TenantId == request.TenantId && x.Vector != null) .OrderBy(x => x.Vector!.CosineDistance(queryVector)) @@ -68,13 +70,13 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler c.Id).ToList(); - var links = await _dbContext.KnowledgeUnitLinks + var links = await dbContext.KnowledgeUnitLinks .AsNoTracking() .Where(l => candidateIds.Contains(l.SourceUnitId) && (l.RelationType == "Defines" || l.RelationType == "Next")) .ToListAsync(cancellationToken); var relatedIds = links.Select(l => l.TargetUnitId).Distinct().ToList(); - var relatedUnits = await _dbContext.KnowledgeUnits + var relatedUnits = await dbContext.KnowledgeUnits .AsNoTracking() .Where(u => relatedIds.Contains(u.Id)) .ToDictionaryAsync(u => u.Id, cancellationToken); diff --git a/src/NexusReader.Application/Security/Authorization/ProUserHandler.cs b/src/NexusReader.Application/Security/Authorization/ProUserHandler.cs index 7d1df9f..41ce839 100644 --- a/src/NexusReader.Application/Security/Authorization/ProUserHandler.cs +++ b/src/NexusReader.Application/Security/Authorization/ProUserHandler.cs @@ -2,16 +2,19 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using NexusReader.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +using NexusReader.Data.Persistence; namespace NexusReader.Application.Security.Authorization; public class ProUserHandler : AuthorizationHandler { - private readonly UserManager _userManager; - - public ProUserHandler(UserManager userManager) + private readonly IDbContextFactory _dbContextFactory; + + public ProUserHandler(IDbContextFactory dbContextFactory) { - _userManager = userManager; + _dbContextFactory = dbContextFactory; } protected override async Task HandleRequirementAsync( @@ -24,14 +27,18 @@ public class ProUserHandler : AuthorizationHandler return; } - var user = await _userManager.FindByIdAsync(userId); + using var db = _dbContextFactory.CreateDbContext(); + var user = await db.Users + .Include(u => u.SubscriptionPlan) + .FirstOrDefaultAsync(u => u.Id == userId); + if (user == null) { return; } - // Rule 1: Explicit Pro plan - if (user.SubscriptionPlanId == SubscriptionPlan.ProId) + // Rule 1: Unlimited access + if (user.SubscriptionPlan?.IsUnlimitedTokens == true) { context.Succeed(requirement); return; diff --git a/src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.Designer.cs b/src/NexusReader.Data/Migrations/20260428184727_InitialPostgres.Designer.cs similarity index 99% rename from src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.Designer.cs rename to src/NexusReader.Data/Migrations/20260428184727_InitialPostgres.Designer.cs index c75383a..391d164 100644 --- a/src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.Designer.cs +++ b/src/NexusReader.Data/Migrations/20260428184727_InitialPostgres.Designer.cs @@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using NexusReader.Infrastructure.Persistence; +using NexusReader.Data.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace NexusReader.Infrastructure.Migrations +namespace NexusReader.Data.Migrations { [DbContext(typeof(AppDbContext))] [Migration("20260428184727_InitialPostgres")] diff --git a/src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.cs b/src/NexusReader.Data/Migrations/20260428184727_InitialPostgres.cs similarity index 99% rename from src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.cs rename to src/NexusReader.Data/Migrations/20260428184727_InitialPostgres.cs index db42422..07a9be4 100644 --- a/src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.cs +++ b/src/NexusReader.Data/Migrations/20260428184727_InitialPostgres.cs @@ -4,7 +4,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace NexusReader.Infrastructure.Migrations +namespace NexusReader.Data.Migrations { /// public partial class InitialPostgres : Migration diff --git a/src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.Designer.cs b/src/NexusReader.Data/Migrations/20260428185239_IncreaseHashLength.Designer.cs similarity index 99% rename from src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.Designer.cs rename to src/NexusReader.Data/Migrations/20260428185239_IncreaseHashLength.Designer.cs index 4072051..df0ac24 100644 --- a/src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.Designer.cs +++ b/src/NexusReader.Data/Migrations/20260428185239_IncreaseHashLength.Designer.cs @@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using NexusReader.Infrastructure.Persistence; +using NexusReader.Data.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace NexusReader.Infrastructure.Migrations +namespace NexusReader.Data.Migrations { [DbContext(typeof(AppDbContext))] [Migration("20260428185239_IncreaseHashLength")] diff --git a/src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.cs b/src/NexusReader.Data/Migrations/20260428185239_IncreaseHashLength.cs similarity index 98% rename from src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.cs rename to src/NexusReader.Data/Migrations/20260428185239_IncreaseHashLength.cs index 331c6ea..f48f22a 100644 --- a/src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.cs +++ b/src/NexusReader.Data/Migrations/20260428185239_IncreaseHashLength.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace NexusReader.Infrastructure.Migrations +namespace NexusReader.Data.Migrations { /// public partial class IncreaseHashLength : Migration diff --git a/src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.Designer.cs b/src/NexusReader.Data/Migrations/20260429080302_AddQuizResults.Designer.cs similarity index 99% rename from src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.Designer.cs rename to src/NexusReader.Data/Migrations/20260429080302_AddQuizResults.Designer.cs index 850470c..210ceb4 100644 --- a/src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.Designer.cs +++ b/src/NexusReader.Data/Migrations/20260429080302_AddQuizResults.Designer.cs @@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using NexusReader.Infrastructure.Persistence; +using NexusReader.Data.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace NexusReader.Infrastructure.Migrations +namespace NexusReader.Data.Migrations { [DbContext(typeof(AppDbContext))] [Migration("20260429080302_AddQuizResults")] diff --git a/src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.cs b/src/NexusReader.Data/Migrations/20260429080302_AddQuizResults.cs similarity index 97% rename from src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.cs rename to src/NexusReader.Data/Migrations/20260429080302_AddQuizResults.cs index 16d9a39..eea03b6 100644 --- a/src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.cs +++ b/src/NexusReader.Data/Migrations/20260429080302_AddQuizResults.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace NexusReader.Infrastructure.Migrations +namespace NexusReader.Data.Migrations { /// public partial class AddQuizResults : Migration diff --git a/src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.Designer.cs b/src/NexusReader.Data/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.Designer.cs similarity index 99% rename from src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.Designer.cs rename to src/NexusReader.Data/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.Designer.cs index fdd5877..e642424 100644 --- a/src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.Designer.cs +++ b/src/NexusReader.Data/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.Designer.cs @@ -4,13 +4,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using NexusReader.Infrastructure.Persistence; +using NexusReader.Data.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Pgvector; #nullable disable -namespace NexusReader.Infrastructure.Migrations +namespace NexusReader.Data.Migrations { [DbContext(typeof(AppDbContext))] [Migration("20260503175906_FinalNormalizedSubscriptionArchitecture")] diff --git a/src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.cs b/src/NexusReader.Data/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.cs similarity index 99% rename from src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.cs rename to src/NexusReader.Data/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.cs index 4c0c18a..1960443 100644 --- a/src/NexusReader.Infrastructure/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.cs +++ b/src/NexusReader.Data/Migrations/20260503175906_FinalNormalizedSubscriptionArchitecture.cs @@ -7,7 +7,7 @@ using Pgvector; #pragma warning disable CA1814 // Prefer jagged arrays over multidimensional -namespace NexusReader.Infrastructure.Migrations +namespace NexusReader.Data.Migrations { /// public partial class FinalNormalizedSubscriptionArchitecture : Migration diff --git a/src/NexusReader.Data/Migrations/20260506184227_UpdateSubscriptionPlanIsUnlimitedTokens.Designer.cs b/src/NexusReader.Data/Migrations/20260506184227_UpdateSubscriptionPlanIsUnlimitedTokens.Designer.cs new file mode 100644 index 0000000..714b54f --- /dev/null +++ b/src/NexusReader.Data/Migrations/20260506184227_UpdateSubscriptionPlanIsUnlimitedTokens.Designer.cs @@ -0,0 +1,659 @@ +// +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("20260506184227_UpdateSubscriptionPlanIsUnlimitedTokens")] + partial class UpdateSubscriptionPlanIsUnlimitedTokens + { + /// + 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("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.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.Data/Migrations/20260506184227_UpdateSubscriptionPlanIsUnlimitedTokens.cs b/src/NexusReader.Data/Migrations/20260506184227_UpdateSubscriptionPlanIsUnlimitedTokens.cs new file mode 100644 index 0000000..6b689fa --- /dev/null +++ b/src/NexusReader.Data/Migrations/20260506184227_UpdateSubscriptionPlanIsUnlimitedTokens.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusReader.Data.Migrations +{ + /// + public partial class UpdateSubscriptionPlanIsUnlimitedTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsUnlimitedTokens", + table: "SubscriptionPlans", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.UpdateData( + table: "SubscriptionPlans", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "AITokenLimit", "IsUnlimitedTokens", "StripeProductId" }, + values: new object[] { 5000, false, "prod_Free789" }); + + migrationBuilder.UpdateData( + table: "SubscriptionPlans", + keyColumn: "Id", + keyValue: 2, + column: "IsUnlimitedTokens", + value: false); + + migrationBuilder.UpdateData( + table: "SubscriptionPlans", + keyColumn: "Id", + keyValue: 3, + column: "IsUnlimitedTokens", + value: false); + + migrationBuilder.UpdateData( + table: "SubscriptionPlans", + keyColumn: "Id", + keyValue: 4, + columns: new[] { "AITokenLimit", "IsUnlimitedTokens" }, + values: new object[] { 1000000000, true }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsUnlimitedTokens", + table: "SubscriptionPlans"); + + migrationBuilder.UpdateData( + table: "SubscriptionPlans", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "AITokenLimit", "StripeProductId" }, + values: new object[] { 1000, "" }); + + migrationBuilder.UpdateData( + table: "SubscriptionPlans", + keyColumn: "Id", + keyValue: 4, + column: "AITokenLimit", + value: 500000); + } + } +} diff --git a/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs similarity index 96% rename from src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs rename to src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs index 3da16d0..9be4777 100644 --- a/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs @@ -3,13 +3,13 @@ using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using NexusReader.Infrastructure.Persistence; +using NexusReader.Data.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Pgvector; #nullable disable -namespace NexusReader.Infrastructure.Migrations +namespace NexusReader.Data.Migrations { [DbContext(typeof(AppDbContext))] partial class AppDbContextModelSnapshot : ModelSnapshot @@ -200,7 +200,7 @@ namespace NexusReader.Infrastructure.Migrations b.HasIndex("UserId"); - b.ToTable("Ebooks"); + b.ToTable("Ebooks", (string)null); }); modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => @@ -246,7 +246,7 @@ namespace NexusReader.Infrastructure.Migrations b.HasIndex("TenantId"); - b.ToTable("KnowledgeUnits"); + b.ToTable("KnowledgeUnits", (string)null); }); modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b => @@ -278,7 +278,7 @@ namespace NexusReader.Infrastructure.Migrations b.HasIndex("TargetUnitId"); - b.ToTable("KnowledgeUnitLinks"); + b.ToTable("KnowledgeUnitLinks", (string)null); }); modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => @@ -413,7 +413,7 @@ namespace NexusReader.Infrastructure.Migrations b.HasIndex("UserId"); - b.ToTable("QuizResults"); + b.ToTable("QuizResults", (string)null); }); modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b => @@ -458,7 +458,7 @@ namespace NexusReader.Infrastructure.Migrations b.HasIndex("TenantId"); - b.ToTable("SemanticKnowledgeCache"); + b.ToTable("SemanticKnowledgeCache", (string)null); }); modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b => @@ -472,6 +472,9 @@ namespace NexusReader.Infrastructure.Migrations b.Property("AITokenLimit") .HasColumnType("integer"); + b.Property("IsUnlimitedTokens") + .HasColumnType("boolean"); + b.Property("MonthlyPrice") .HasColumnType("numeric"); @@ -490,21 +493,23 @@ namespace NexusReader.Infrastructure.Migrations b.HasIndex("PlanName") .IsUnique(); - b.ToTable("SubscriptionPlans"); + b.ToTable("SubscriptionPlans", (string)null); b.HasData( new { Id = 1, - AITokenLimit = 1000, + AITokenLimit = 5000, + IsUnlimitedTokens = false, MonthlyPrice = 0m, PlanName = "Free", - StripeProductId = "" + StripeProductId = "prod_Free789" }, new { Id = 2, AITokenLimit = 10000, + IsUnlimitedTokens = false, MonthlyPrice = 9.99m, PlanName = "Basic", StripeProductId = "prod_basic_placeholder" @@ -513,6 +518,7 @@ namespace NexusReader.Infrastructure.Migrations { Id = 3, AITokenLimit = 50000, + IsUnlimitedTokens = false, MonthlyPrice = 19.99m, PlanName = "Pro", StripeProductId = "prod_pro_placeholder" @@ -520,7 +526,8 @@ namespace NexusReader.Infrastructure.Migrations new { Id = 4, - AITokenLimit = 500000, + AITokenLimit = 1000000000, + IsUnlimitedTokens = true, MonthlyPrice = 99.99m, PlanName = "Enterprise", StripeProductId = "prod_enterprise_placeholder" diff --git a/src/NexusReader.Data/NexusReader.Data.csproj b/src/NexusReader.Data/NexusReader.Data.csproj new file mode 100644 index 0000000..b480df1 --- /dev/null +++ b/src/NexusReader.Data/NexusReader.Data.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs b/src/NexusReader.Data/Persistence/AppDbContext.cs similarity index 78% rename from src/NexusReader.Infrastructure/Persistence/AppDbContext.cs rename to src/NexusReader.Data/Persistence/AppDbContext.cs index 96c378d..16ed15e 100644 --- a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs +++ b/src/NexusReader.Data/Persistence/AppDbContext.cs @@ -1,16 +1,23 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using NexusReader.Domain.Entities; -using NexusReader.Application.Abstractions.Persistence; -namespace NexusReader.Infrastructure.Persistence; -public class AppDbContext : IdentityDbContext, IApplicationDbContext +namespace NexusReader.Data.Persistence; + +public class AppDbContext : IdentityDbContext { public AppDbContext(DbContextOptions options) : base(options) { } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + // Suppress the pending model changes warning to avoid runtime exceptions in some environments + optionsBuilder.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); + } + public DbSet SemanticKnowledgeCache => Set(); public DbSet KnowledgeUnits => Set(); public DbSet KnowledgeUnitLinks => Set(); @@ -20,6 +27,8 @@ public class AppDbContext : IdentityDbContext, IApplicationDbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + modelBuilder.HasPostgresExtension("vector"); modelBuilder.Entity(entity => @@ -38,8 +47,6 @@ public class AppDbContext : IdentityDbContext, IApplicationDbContext .HasDefaultValue(1); }); - base.OnModelCreating(modelBuilder); - modelBuilder.Entity(entity => { entity.HasIndex(p => p.PlanName).IsUnique(); @@ -97,10 +104,10 @@ public class AppDbContext : IdentityDbContext, IApplicationDbContext // 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" } + new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" }, + new SubscriptionPlan { Id = 2, PlanName = SubscriptionPlan.BasicName, AITokenLimit = 10000, IsUnlimitedTokens = false, MonthlyPrice = 9.99m, StripeProductId = "prod_basic_placeholder" }, + new SubscriptionPlan { Id = 3, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, IsUnlimitedTokens = false, MonthlyPrice = 19.99m, StripeProductId = "prod_pro_placeholder" }, + new SubscriptionPlan { Id = 4, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 1000000000, IsUnlimitedTokens = true, MonthlyPrice = 99.99m, StripeProductId = "prod_enterprise_placeholder" } ); } } diff --git a/src/NexusReader.Infrastructure/Persistence/AppDbContextFactory.cs b/src/NexusReader.Data/Persistence/AppDbContextFactory.cs similarity index 97% rename from src/NexusReader.Infrastructure/Persistence/AppDbContextFactory.cs rename to src/NexusReader.Data/Persistence/AppDbContextFactory.cs index 24fa45c..9c2f3a1 100644 --- a/src/NexusReader.Infrastructure/Persistence/AppDbContextFactory.cs +++ b/src/NexusReader.Data/Persistence/AppDbContextFactory.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; using Pgvector.EntityFrameworkCore; -namespace NexusReader.Infrastructure.Persistence; +namespace NexusReader.Data.Persistence; public class AppDbContextFactory : IDesignTimeDbContextFactory { diff --git a/src/NexusReader.Infrastructure/Persistence/DbInitializer.cs b/src/NexusReader.Data/Persistence/DbInitializer.cs similarity index 56% rename from src/NexusReader.Infrastructure/Persistence/DbInitializer.cs rename to src/NexusReader.Data/Persistence/DbInitializer.cs index 2a883e7..3a30e35 100644 --- a/src/NexusReader.Infrastructure/Persistence/DbInitializer.cs +++ b/src/NexusReader.Data/Persistence/DbInitializer.cs @@ -7,16 +7,16 @@ using System.Threading.Tasks; using System.Collections.Generic; using Microsoft.EntityFrameworkCore; -namespace NexusReader.Infrastructure.Persistence; +namespace NexusReader.Data.Persistence; public static class DbInitializer { public static async Task SeedAsync(IServiceProvider serviceProvider) { using var scope = serviceProvider.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - var dbContext = scope.ServiceProvider.GetRequiredService(); + var passwordHasher = scope.ServiceProvider.GetRequiredService>(); + var dbContextFactory = scope.ServiceProvider.GetRequiredService>(); + using var dbContext = await dbContextFactory.CreateDbContextAsync(); try { @@ -27,9 +27,9 @@ public static class DbInitializer { 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" } + new SubscriptionPlan { Id = SubscriptionPlan.FreeId, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0, StripeProductId = "prod_Free789" }, + new SubscriptionPlan { Id = SubscriptionPlan.ProId, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, IsUnlimitedTokens = false, MonthlyPrice = 19, StripeProductId = "prod_Pro123" }, + new SubscriptionPlan { Id = SubscriptionPlan.EnterpriseId, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 1000000000, IsUnlimitedTokens = true, MonthlyPrice = 99, StripeProductId = "prod_Enterprise456" } }); await dbContext.SaveChangesAsync(); Console.WriteLine("[Seeder] Subscription plans seeded."); @@ -39,41 +39,45 @@ public static class DbInitializer string[] roleNames = { "Admin", "User" }; foreach (var roleName in roleNames) { - var roleExist = await roleManager.RoleExistsAsync(roleName); + var roleExist = dbContext.Roles.Any(r => r.Name == roleName); if (!roleExist) { - await roleManager.CreateAsync(new IdentityRole(roleName)); + dbContext.Roles.Add(new IdentityRole { Name = roleName, NormalizedName = roleName.ToUpper() }); Console.WriteLine($"[Seeder] Created role: {roleName}"); } } + await dbContext.SaveChangesAsync(); // Seed Admin User var adminEmail = "admin@nexus.com"; - var adminUser = await userManager.FindByEmailAsync(adminEmail); + var normalizedEmail = adminEmail.ToUpper(); + var adminUser = await dbContext.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail); if (adminUser == null) { adminUser = new NexusUser { UserName = adminEmail, + NormalizedUserName = normalizedEmail, Email = adminEmail, + NormalizedEmail = normalizedEmail, EmailConfirmed = true, SubscriptionPlanId = SubscriptionPlan.EnterpriseId, AITokenLimit = 1000000, - TenantId = Guid.NewGuid().ToString() + TenantId = Guid.NewGuid().ToString(), + SecurityStamp = Guid.NewGuid().ToString() }; - var createPowerUser = await userManager.CreateAsync(adminUser, "Admin123!"); - if (createPowerUser.Succeeded) - { - await userManager.AddToRoleAsync(adminUser, "Admin"); - Console.WriteLine($"[Seeder] Admin user created successfully: {adminEmail}"); - } - else - { - var errors = string.Join(", ", createPowerUser.Errors.Select(e => e.Description)); - Console.WriteLine($"[Seeder] Failed to create admin user: {errors}"); - } + adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!"); + + dbContext.Users.Add(adminUser); + await dbContext.SaveChangesAsync(); + + var adminRole = await dbContext.Roles.FirstAsync(r => r.Name == "Admin"); + dbContext.UserRoles.Add(new IdentityUserRole { UserId = adminUser.Id, RoleId = adminRole.Id }); + await dbContext.SaveChangesAsync(); + + Console.WriteLine($"[Seeder] Admin user created successfully: {adminEmail}"); } else { diff --git a/src/NexusReader.Domain/Entities/SubscriptionPlan.cs b/src/NexusReader.Domain/Entities/SubscriptionPlan.cs index 481848c..db8d0db 100644 --- a/src/NexusReader.Domain/Entities/SubscriptionPlan.cs +++ b/src/NexusReader.Domain/Entities/SubscriptionPlan.cs @@ -22,6 +22,8 @@ public class SubscriptionPlan public string PlanName { get; set; } = string.Empty; public int AITokenLimit { get; set; } + + public bool IsUnlimitedTokens { get; set; } public decimal MonthlyPrice { get; set; } diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 2ce0496..2b03646 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -5,7 +5,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; using GeminiDotnet; using GeminiDotnet.Extensions.AI; -using NexusReader.Infrastructure.Persistence; +using NexusReader.Data.Persistence; + using NexusReader.Application.Abstractions.Services; using NexusReader.Infrastructure.Services; using NexusReader.Infrastructure.Configuration; diff --git a/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs b/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs index 269e8fb..1f5d82f 100644 --- a/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs +++ b/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs @@ -4,27 +4,28 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using NexusReader.Application.Commands.Sync; using NexusReader.Domain.Entities; -using NexusReader.Infrastructure.Persistence; +using NexusReader.Data.Persistence; using NexusReader.Infrastructure.RealTime; namespace NexusReader.Infrastructure.Handlers; public class UpdateReadingProgressCommandHandler : IRequestHandler { - private readonly AppDbContext _context; + private readonly IDbContextFactory _dbContextFactory; private readonly IHubContext _hubContext; public UpdateReadingProgressCommandHandler( - AppDbContext context, + IDbContextFactory dbContextFactory, IHubContext hubContext) { - _context = context; + _dbContextFactory = dbContextFactory; _hubContext = hubContext; } public async Task Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken) { - var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); if (user == null) { return Result.Fail("User not found."); @@ -34,7 +35,7 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler return; } - var user = await _userManager.FindByIdAsync(userId); + using var db = _dbContextFactory.CreateDbContext(); + var user = await db.Users + .Include(u => u.SubscriptionPlan) + .FirstOrDefaultAsync(u => u.Id == userId); + if (user == null) { return; } - // Check if user has available tokens - if (user.AITokensUsed < user.AITokenLimit) + // Check if user has available tokens or unlimited plan + if (user.SubscriptionPlan?.IsUnlimitedTokens == true || user.AITokensUsed < user.AITokenLimit) { context.Succeed(requirement); } diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index 00624de..67e1161 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -2,6 +2,7 @@ + diff --git a/src/NexusReader.Infrastructure/Services/BillingService.cs b/src/NexusReader.Infrastructure/Services/BillingService.cs index cc04fa5..6705a24 100644 --- a/src/NexusReader.Infrastructure/Services/BillingService.cs +++ b/src/NexusReader.Infrastructure/Services/BillingService.cs @@ -5,24 +5,24 @@ using Microsoft.Extensions.Options; using NexusReader.Application.Abstractions.Services; using NexusReader.Domain.Entities; using NexusReader.Infrastructure.Configuration; -using NexusReader.Infrastructure.Persistence; +using NexusReader.Data.Persistence; namespace NexusReader.Infrastructure.Services; public class BillingService : IBillingService { - private readonly AppDbContext _dbContext; + private readonly IDbContextFactory _dbContextFactory; private readonly UserManager _userManager; private readonly StripeSettings _stripeSettings; private readonly ILogger _logger; public BillingService( - AppDbContext dbContext, + IDbContextFactory dbContextFactory, UserManager userManager, IOptions stripeSettings, ILogger logger) { - _dbContext = dbContext; + _dbContextFactory = dbContextFactory; _userManager = userManager; _stripeSettings = stripeSettings.Value; _logger = logger; @@ -55,7 +55,8 @@ public class BillingService : IBillingService _logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail); } - var plan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName); + using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName); if (plan != null) { user.SubscriptionPlanId = plan.Id; @@ -82,7 +83,8 @@ public class BillingService : IBillingService return false; } - var freePlan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName); + using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + var freePlan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName); if (freePlan != null) { user.SubscriptionPlanId = freePlan.Id; diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index fe4e529..2880aeb 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -7,7 +7,7 @@ using NexusReader.Application.Abstractions.Services; using NexusReader.Application.DTOs.AI; using NexusReader.Domain.Entities; using NexusReader.Infrastructure.Helpers; -using NexusReader.Infrastructure.Persistence; +using NexusReader.Data.Persistence; using Polly; using Polly.Registry; using Microsoft.Extensions.Options; diff --git a/src/NexusReader.UI.Shared/Pages/Account/Login.razor b/src/NexusReader.UI.Shared/Pages/Account/Login.razor index e006433..9635375 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Login.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Login.razor @@ -59,6 +59,13 @@
@_errorMessage
} +
+ +
+