From 0210611edfde552c5281b0351bcf499f6ccc878c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Wed, 29 Apr 2026 20:37:41 +0200 Subject: [PATCH] feat: implement identity authentication, authorization policies, and MAUI platform support with Docker orchestration --- .agents/agents.md | 3 +- .agents/skills/dotnet-async-void.md | 54 +++ .dockerignore | 12 + Dockerfile | 31 ++ backlog-identity.md | 23 +- docker-compose.yml | 37 ++ .../Abstractions/Services/IBillingService.cs | 9 + .../NexusReader.Application.csproj | 1 + src/NexusReader.Domain/Entities/NexusUser.cs | 5 + src/NexusReader.Domain/Entities/QuizResult.cs | 30 ++ .../Entities/SemanticKnowledgeCache.cs | 2 +- .../NexusReader.Domain.csproj | 1 + .../DependencyInjection.cs | 15 +- ...0260428184727_InitialPostgres.Designer.cs} | 123 ++--- ...s.cs => 20260428184727_InitialPostgres.cs} | 115 ++--- ...60428185239_IncreaseHashLength.Designer.cs | 377 ++++++++++++++++ .../20260428185239_IncreaseHashLength.cs | 89 ++++ .../20260429080302_AddQuizResults.Designer.cs | 420 ++++++++++++++++++ .../20260429080302_AddQuizResults.cs | 49 ++ .../Migrations/AppDbContextModelSnapshot.cs | 164 ++++--- .../NexusReader.Infrastructure.csproj | 2 + .../Persistence/AppDbContext.cs | 9 + .../Services/BillingService.cs | 53 +++ src/NexusReader.Maui/MauiProgram.cs | 59 +-- .../Platforms/Android/AndroidManifest.xml | 5 + .../Platforms/Android/MainActivity.cs | 19 + .../Platforms/Android/MainApplication.cs | 34 ++ .../Android/Resources/values/colors.xml | 6 + .../Android/Resources/values/styles.xml | 13 + .../Resources/Styles/Styles.xaml | 2 +- .../Services/MauiStorageService.cs | 110 +++++ .../Components/Atoms/NexusIcon.razor | 12 + .../Components/Organisms/ReaderCanvas.razor | 4 +- .../Components/Organisms/ReaderFooter.razor | 10 +- .../Components/RedirectToLogin.razor | 16 + .../Layout/AuthLayout.razor | 24 + .../Layout/MainLayout.razor | 45 +- .../NexusReader.UI.Shared.csproj | 1 + .../Pages/Account/Login.razor | 124 +++--- .../Pages/Account/Login.razor.css | 223 ---------- .../Pages/Account/Profile.razor | 121 +++-- .../Pages/Account/Profile.razor.css | 341 ++++++++------ .../Pages/Account/Register.razor | 100 ++--- .../Pages/Account/Register.razor.css | 174 -------- src/NexusReader.UI.Shared/Pages/Home.razor | 1 + src/NexusReader.UI.Shared/Routes.razor | 2 +- .../Services/IReaderNavigationService.cs | 8 +- .../Services/IdentityService.cs | 38 +- .../Services/ReaderNavigationService.cs | 32 +- .../Services/WebStorageService.cs | 9 +- src/NexusReader.UI.Shared/_Imports.razor | 2 + .../wwwroot/css/nexus-auth.css | 98 ++++ src/NexusReader.Web.Client/Program.cs | 14 +- src/NexusReader.Web.New/Program.cs | 30 +- src/NexusReader.Web.New/appsettings.json | 7 +- 55 files changed, 2359 insertions(+), 949 deletions(-) create mode 100644 .agents/skills/dotnet-async-void.md create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/NexusReader.Application/Abstractions/Services/IBillingService.cs create mode 100644 src/NexusReader.Domain/Entities/QuizResult.cs rename src/NexusReader.Infrastructure/Migrations/{20260428142027_InitialIdentityAndEbooks.Designer.cs => 20260428184727_InitialPostgres.Designer.cs} (74%) rename src/NexusReader.Infrastructure/Migrations/{20260428142027_InitialIdentityAndEbooks.cs => 20260428184727_InitialPostgres.cs} (68%) create mode 100644 src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.Designer.cs create mode 100644 src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.cs create mode 100644 src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.Designer.cs create mode 100644 src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.cs create mode 100644 src/NexusReader.Infrastructure/Services/BillingService.cs create mode 100644 src/NexusReader.Maui/Platforms/Android/AndroidManifest.xml create mode 100644 src/NexusReader.Maui/Platforms/Android/MainActivity.cs create mode 100644 src/NexusReader.Maui/Platforms/Android/MainApplication.cs create mode 100644 src/NexusReader.Maui/Platforms/Android/Resources/values/colors.xml create mode 100644 src/NexusReader.Maui/Platforms/Android/Resources/values/styles.xml create mode 100644 src/NexusReader.Maui/Services/MauiStorageService.cs create mode 100644 src/NexusReader.UI.Shared/Components/RedirectToLogin.razor create mode 100644 src/NexusReader.UI.Shared/Layout/AuthLayout.razor delete mode 100644 src/NexusReader.UI.Shared/Pages/Account/Login.razor.css delete mode 100644 src/NexusReader.UI.Shared/Pages/Account/Register.razor.css rename src/{NexusReader.Web.New => NexusReader.UI.Shared}/Services/WebStorageService.cs (77%) create mode 100644 src/NexusReader.UI.Shared/wwwroot/css/nexus-auth.css diff --git a/.agents/agents.md b/.agents/agents.md index 22136d3..60be4a6 100644 --- a/.agents/agents.md +++ b/.agents/agents.md @@ -4,10 +4,11 @@ - **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor) - **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI". - **Architecture Role:** Lead Clean Architecture Specialist. -- **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator, nexus-identity-saas] +- **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator, nexus-identity-saas, dotnet-async-void] - **Technical Constraints:** - **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level. - **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components. + - **Async:** Strict zero-tolerance for `async void`. All async operations must return `Task` or `ValueTask`. Event handlers must use `Func` or async-compatible patterns. - **Error Handling:** All handlers must return `Result` via `FluentResult`. - **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper. - **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance. diff --git a/.agents/skills/dotnet-async-void.md b/.agents/skills/dotnet-async-void.md new file mode 100644 index 0000000..62cc985 --- /dev/null +++ b/.agents/skills/dotnet-async-void.md @@ -0,0 +1,54 @@ +# .NET Async Best Practices: Avoiding `async void` + +This document defines the core rules for handling asynchronous operations in .NET, specifically focused on the dangers of `async void`. + +## The Rule: NEVER Use `async void` +`async void` is a critical anti-pattern in .NET that must be avoided in almost all scenarios. + +### Why `async void` is Dangerous: +1. **Untraceable Crashes**: Exceptions thrown in an `async void` method cannot be caught by a `try-catch` block outside the method. They often crash the entire process. +2. **Impossible to Await**: Callers cannot know when the operation has finished, leading to race conditions and "disposed object" exceptions (especially with `DbContext`). +3. **Broken Lifecycle**: In Blazor and ASP.NET Core, the DI container might dispose of scoped services (like `DbContext`) before the `async void` method completes its work. + +## Correct Patterns + +### 1. Standard Methods +Always return `Task` or `ValueTask` instead of `void`. +```csharp +// BAD +public async void SaveDataAsync() { ... } + +// GOOD +public async Task SaveDataAsync() { ... } +``` + +### 2. Async Events (Blazor/Services) +When dealing with events that are defined as `Action` or `EventHandler`, do not use `async void` in the handler. Instead, refactor the event to support `Task`. + +#### Pattern A: Use `Func` for Events +Instead of `Action`, use `Func` so the invoker can await all handlers. +```csharp +// Interface +event Func? OnChanged; + +// Invoker +if (OnChanged != null) +{ + foreach (var handler in OnChanged.GetInvocationList().Cast>()) + { + await handler(); + } +} +``` + +#### Pattern B: Wrapper for Legacy Events +If you *must* subscribe to a legacy `void` event, use a Task-returning method and a lambda, but be aware of the "fire-and-forget" risks. +```csharp +// Better than async void, but still has lifecycle risks +NavigationService.OnChanged += () => { _ = HandleChangedAsync(); }; +``` + +## Special Exceptions +The ONLY valid place for `async void` is in **Top-level Event Handlers** in legacy UI frameworks (WinForms/WPF) where the event signature is fixed and cannot be changed. Even then, the method body should be wrapped in a `try-catch` that logs or handles all exceptions. + +In modern Blazor and Web development, there is **zero justification** for `async void`. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b447bd5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +**/.git/ +**/bin/ +**/obj/ +**/out/ +**/dist/ +**/.vscode/ +**/.vs/ +**/node_modules/ +docker-compose* +Dockerfile* +.env +nexus.db* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c76b917 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Stage 1: Build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy csproj files and restore dependencies +COPY ["src/NexusReader.Web.New/NexusReader.Web.csproj", "src/NexusReader.Web.New/"] +COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"] +COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"] +COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"] +COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"] +COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"] + +RUN dotnet restore "src/NexusReader.Web.New/NexusReader.Web.csproj" + +# Copy the rest of the source code +COPY . . + +# Build and publish +WORKDIR "/src/src/NexusReader.Web.New" +RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Stage 2: Runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +COPY --from=build /app/publish . + +# Environment variables +ENV ASPNETCORE_URLS=http://+:5000 +EXPOSE 5000 + +ENTRYPOINT ["dotnet", "NexusReader.Web.dll"] diff --git a/backlog-identity.md b/backlog-identity.md index 510b085..b547acc 100644 --- a/backlog-identity.md +++ b/backlog-identity.md @@ -10,10 +10,10 @@ | ID | Task Title | Description & Acceptance Criteria | Tech Stack | |:---|:---|:---|:---| -| **BACK-1** | Define Extended `NexusUser` Model | **Description:** Create a `NexusUser` class inheriting from `IdentityUser`. Add custom properties for SaaS logic.
**AC:**
- [ ] Properties added: `AITokenLimit` (int), `AITokensUsed` (int), `TenantId` (Guid), `CurrentPlan` (string).
- [ ] Model placed in `NexusArchitect.Core` project. | C# / Identity | -| **BACK-2** | Configure `ApplicationDbContext` for Identity | **Description:** Set up the DB context to inherit from `IdentityDbContext`.
**AC:**
- [ ] Mapped standard Identity tables (Users, Roles, Claims).
- [ ] Configured 1-to-Many relationship between `NexusUser` and `Ebooks`. | EF Core | -| **BACK-3** | Database Schema Migration | **Description:** Generate and apply the initial migration for Identity tables.
**AC:**
- [ ] SQL schema contains all 7+ standard Identity tables.
- [ ] Custom `NexusUser` fields are correctly reflected in the `AspNetUsers` table. | EF Core CLI | -| **BACK-4** | Implement Identity API Endpoints | **Description:** Enable native .NET Identity API endpoints in `Program.cs`.
**AC:**
- [ ] Endpoints `/register`, `/login`, and `/refresh` are active.
- [ ] Verified functionality via Swagger/OpenAPI. | ASP.NET Core | +| **BACK-1** | Define Extended `NexusUser` Model | **Description:** Create a `NexusUser` class inheriting from `IdentityUser`. Add custom properties for SaaS logic.
**AC:**
- [x] Properties added: `AITokenLimit` (int), `AITokensUsed` (int), `TenantId` (Guid), `CurrentPlan` (string).
- [x] Model placed in `NexusArchitect.Core` project. | C# / Identity | +| **BACK-2** | Configure `ApplicationDbContext` for Identity | **Description:** Set up the DB context to inherit from `IdentityDbContext`.
**AC:**
- [x] Mapped standard Identity tables (Users, Roles, Claims).
- [x] Configured 1-to-Many relationship between `NexusUser` and `Ebooks`. | EF Core | +| **BACK-3** | Database Schema Migration | **Description:** Generate and apply the initial migration for Identity tables.
**AC:**
- [x] SQL schema contains all 7+ standard Identity tables.
- [x] Custom `NexusUser` fields are correctly reflected in the `AspNetUsers` table. | EF Core CLI | +| **BACK-4** | Implement Identity API Endpoints | **Description:** Enable native .NET Identity API endpoints in `Program.cs`.
**AC:**
- [x] Endpoints `/register`, `/login`, and `/refresh` are active.
- [x] Verified functionality via Swagger/OpenAPI. | ASP.NET Core | --- @@ -21,10 +21,10 @@ | ID | Task Title | Description & Acceptance Criteria | Tech Stack | |:---|:---|:---|:---| -| **BACK-5** | Define Authorization Policies | **Description:** Implement Roles and Claims-based authorization (Free vs. Pro).
**AC:**
- [ ] Created a `ProUser` policy.
- [ ] Implemented a custom `Requirement` to check if `AITokensUsed < AITokenLimit`. | ASP.NET Core | -| **UI-1** | Implement Login Page (Blazor) | **Description:** Build the Login UI based on the Dark Mode mockup.
**AC:**
- [ ] Theme: Dark mode with neon green accents.
- [ ] Components: Email/Password fields, "Remember Me" toggle, "Login" button.
- [ ] Integrates with `AuthenticationStateProvider`. | Blazor / CSS | -| **UI-2** | Google OAuth2 Integration | **Description:** Configure external login provider (Google) in the backend and UI.
**AC:**
- [ ] Users can sign in via Google button.
- [ ] New users are automatically provisioned in the database upon successful OAuth. | OAuth / Google Cloud | -| **UI-3** | Implement Registration Flow | **Description:** Create a registration form calling the `/register` endpoint.
**AC:**
- [ ] Validation: Email format, password complexity (min 8 chars, uppercase, digit).
- [ ] Proper error handling for existing users. | Blazor | +| **BACK-5** | Define Authorization Policies | **Description:** Implement Roles and Claims-based authorization (Free vs. Pro).
**AC:**
- [x] Created a `ProUser` policy.
- [x] Implemented a custom `Requirement` to check if `AITokensUsed < AITokenLimit`. | ASP.NET Core | +| **UI-1** | Implement Login Page (Blazor) | **Description:** Build the Login UI based on the Dark Mode mockup.
**AC:**
- [x] Theme: Dark mode with neon green accents.
- [x] Components: Email/Password fields, "Remember Me" toggle, "Login" button.
- [x] Integrates with `AuthenticationStateProvider`. | Blazor / CSS | +| **UI-2** | Google OAuth2 Integration | **Description:** Configure external login provider (Google) in the backend and UI.
**AC:**
- [x] Users can sign in via Google button.
- [x] New users are automatically provisioned in the database upon successful OAuth. | OAuth / Google Cloud | +| **UI-3** | Implement Registration Flow | **Description:** Create a registration form calling the `/register` endpoint.
**AC:**
- [x] Validation: Email format, password complexity (min 8 chars, uppercase, digit).
- [x] Proper error handling for existing users. | Blazor | --- @@ -32,9 +32,10 @@ | ID | Task Title | Description & Acceptance Criteria | Tech Stack | |:---|:---|:---|:---| -| **UI-4** | User Profile & Dashboard | **Description:** Build the User Profile UI focusing on "Active Learning" metrics.
**AC:**
- [ ] Displays: Token usage bar (Used/Limit), average quiz score, and last read book.
- [ ] Links to subscription management. | Blazor | -| **MAUI-1** | Mobile Auth Integration (Blazor Hybrid) | **Description:** Ensure the authentication state is shared and persists in the MAUI container.
**AC:**
- [ ] Securely store JWT tokens in `SecureStorage`.
- [ ] Automatic login on app launch if token is valid. | MAUI / Blazor Hybrid | -| **INTEG-1** | Stripe Subscription Webhooks | **Description:** Sync Identity Claims with Stripe subscription status.
**AC:**
- [ ] Webhook updates `AITokenLimit` when a "Pro" plan is purchased.
- [ ] User is downgraded back to "Free" limit upon cancellation. | Stripe SDK / .NET | +| **UI-4** | User Profile & Dashboard | **Description:** Build the User Profile UI focusing on "Active Learning" metrics.
**AC:**
- [x] Displays: Token usage bar (Used/Limit), average quiz score, and last read book.
- [x] Links to subscription management. | Blazor | +| **MAUI-1** | Mobile Auth Integration (Blazor Hybrid) | **Description:** Ensure the authentication state is shared and persists in the MAUI container.
**AC:**
- [x] Securely store JWT tokens in `SecureStorage`.
- [x] Automatic login on app launch if token is valid. | MAUI / Blazor Hybrid | +| **MAUI-2** | Secure Session Persistence | **Description:** Implement long-lived session management using encrypted device storage.
**AC:**
- [x] Refresh tokens implementation for mobile.
- [x] "Stay Signed In" functionality. | MAUI / Identity | +| **INTEG-1** | Stripe Subscription Webhooks | **Description:** Sync Identity Claims with Stripe subscription status.
**AC:**
- [x] Webhook updates `AITokenLimit` when a "Pro" plan is purchased.
- [x] User is downgraded back to "Free" limit upon cancellation. | Stripe SDK / .NET | --- diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bd14af7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + db: + image: postgres:17-alpine + container_name: nexus-db + environment: + POSTGRES_USER: nexus_user + POSTGRES_PASSWORD: nexus_password + POSTGRES_DB: nexus_db + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nexus_user -d nexus_db"] + interval: 5s + timeout: 5s + retries: 5 + + web: + build: + context: . + dockerfile: Dockerfile + container_name: nexus-web + ports: + - "5000:5000" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ConnectionStrings__PostgresConnection=Host=db;Database=nexus_db;Username=nexus_user;Password=nexus_password + - Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder} + - Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder} + - Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder} + depends_on: + db: + condition: service_healthy + +volumes: + pgdata: diff --git a/src/NexusReader.Application/Abstractions/Services/IBillingService.cs b/src/NexusReader.Application/Abstractions/Services/IBillingService.cs new file mode 100644 index 0000000..d86fd8c --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IBillingService.cs @@ -0,0 +1,9 @@ +using NexusReader.Domain.Entities; + +namespace NexusReader.Application.Abstractions.Services; + +public interface IBillingService +{ + Task HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId); + Task HandleSubscriptionDeletedAsync(string customerEmail); +} diff --git a/src/NexusReader.Application/NexusReader.Application.csproj b/src/NexusReader.Application/NexusReader.Application.csproj index 6393116..d59adf5 100644 --- a/src/NexusReader.Application/NexusReader.Application.csproj +++ b/src/NexusReader.Application/NexusReader.Application.csproj @@ -17,6 +17,7 @@ net10.0 enable enable + true diff --git a/src/NexusReader.Domain/Entities/NexusUser.cs b/src/NexusReader.Domain/Entities/NexusUser.cs index d698555..abe6404 100644 --- a/src/NexusReader.Domain/Entities/NexusUser.cs +++ b/src/NexusReader.Domain/Entities/NexusUser.cs @@ -31,4 +31,9 @@ public class NexusUser : IdentityUser /// Collection of e-books owned by the user. /// public ICollection Ebooks { get; set; } = new List(); + + /// + /// Collection of quiz results completed by the user. + /// + public ICollection QuizResults { get; set; } = new List(); } diff --git a/src/NexusReader.Domain/Entities/QuizResult.cs b/src/NexusReader.Domain/Entities/QuizResult.cs new file mode 100644 index 0000000..d908d8c --- /dev/null +++ b/src/NexusReader.Domain/Entities/QuizResult.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace NexusReader.Domain.Entities; + +/// +/// Tracks the result of an AI-generated quiz completed by a user. +/// +public class QuizResult +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public string UserId { get; set; } = string.Empty; + + [ForeignKey(nameof(UserId))] + public NexusUser? User { get; set; } + + [Required] + public string Topic { get; set; } = string.Empty; + + public int Score { get; set; } + + public int TotalQuestions { get; set; } + + public double Percentage => TotalQuestions > 0 ? (double)Score / TotalQuestions * 100 : 0; + + public DateTime CompletedDate { get; set; } = DateTime.UtcNow; +} diff --git a/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs b/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs index 1c0d9b9..f4758b0 100644 --- a/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs +++ b/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs @@ -5,7 +5,7 @@ namespace NexusReader.Domain.Entities; public class SemanticKnowledgeCache { [Key] - [MaxLength(64)] + [MaxLength(128)] public string ContentHash { get; set; } = string.Empty; [Required] diff --git a/src/NexusReader.Domain/NexusReader.Domain.csproj b/src/NexusReader.Domain/NexusReader.Domain.csproj index c61f30e..c911261 100644 --- a/src/NexusReader.Domain/NexusReader.Domain.csproj +++ b/src/NexusReader.Domain/NexusReader.Domain.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + true diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index acf4bd5..66894c7 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -21,9 +21,18 @@ public static class DependencyInjection { public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) { - var connectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db"; - services.AddDbContext(options => - options.UseSqlite(connectionString)); + var pgConnectionString = configuration.GetConnectionString("PostgresConnection"); + if (!string.IsNullOrEmpty(pgConnectionString)) + { + services.AddDbContext(options => + options.UseNpgsql(pgConnectionString)); + } + else + { + var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db"; + services.AddDbContext(options => + options.UseSqlite(sqliteConnectionString)); + } services.Configure(configuration.GetSection(AiSettings.SectionName)); var aiSettings = configuration.GetSection(AiSettings.SectionName).Get() ?? new AiSettings(); diff --git a/src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.Designer.cs b/src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.Designer.cs similarity index 74% rename from src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.Designer.cs rename to src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.Designer.cs index cfcc443..c75383a 100644 --- a/src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.Designer.cs +++ b/src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.Designer.cs @@ -5,37 +5,42 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using NexusReader.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable namespace NexusReader.Infrastructure.Migrations { [DbContext(typeof(AppDbContext))] - [Migration("20260428142027_InitialIdentityAndEbooks")] - partial class InitialIdentityAndEbooks + [Migration("20260428184727_InitialPostgres")] + partial class InitialPostgres { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ConcurrencyStamp") .IsConcurrencyToken() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("Name") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.Property("NormalizedName") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.HasKey("Id"); @@ -50,17 +55,19 @@ namespace NexusReader.Infrastructure.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("ClaimType") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ClaimValue") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("RoleId") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("Id"); @@ -73,17 +80,19 @@ namespace NexusReader.Infrastructure.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("ClaimType") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ClaimValue") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("UserId") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("Id"); @@ -95,17 +104,17 @@ namespace NexusReader.Infrastructure.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ProviderKey") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ProviderDisplayName") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("UserId") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("LoginProvider", "ProviderKey"); @@ -117,10 +126,10 @@ namespace NexusReader.Infrastructure.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("RoleId") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("UserId", "RoleId"); @@ -132,16 +141,16 @@ namespace NexusReader.Infrastructure.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { b.Property("UserId") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("LoginProvider") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("Name") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("Value") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("UserId", "LoginProvider", "Name"); @@ -152,34 +161,34 @@ namespace NexusReader.Infrastructure.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); + .HasColumnType("uuid"); b.Property("AddedDate") - .HasColumnType("TEXT"); + .HasColumnType("timestamp with time zone"); b.Property("Author") .IsRequired() .HasMaxLength(255) - .HasColumnType("TEXT"); + .HasColumnType("character varying(255)"); b.Property("CoverUrl") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("FilePath") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("LastReadDate") - .HasColumnType("TEXT"); + .HasColumnType("timestamp with time zone"); b.Property("Title") .IsRequired() .HasMaxLength(255) - .HasColumnType("TEXT"); + .HasColumnType("character varying(255)"); b.Property("UserId") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("Id"); @@ -191,67 +200,67 @@ namespace NexusReader.Infrastructure.Migrations modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => { b.Property("Id") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("AITokenLimit") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.Property("AITokensUsed") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.Property("AccessFailedCount") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.Property("ConcurrencyStamp") .IsConcurrencyToken() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("CurrentPlan") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("Email") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.Property("EmailConfirmed") - .HasColumnType("INTEGER"); + .HasColumnType("boolean"); b.Property("LockoutEnabled") - .HasColumnType("INTEGER"); + .HasColumnType("boolean"); b.Property("LockoutEnd") - .HasColumnType("TEXT"); + .HasColumnType("timestamp with time zone"); b.Property("NormalizedEmail") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.Property("NormalizedUserName") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.Property("PasswordHash") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("PhoneNumber") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("PhoneNumberConfirmed") - .HasColumnType("INTEGER"); + .HasColumnType("boolean"); b.Property("SecurityStamp") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("TenantId") - .HasColumnType("TEXT"); + .HasColumnType("uuid"); b.Property("TwoFactorEnabled") - .HasColumnType("INTEGER"); + .HasColumnType("boolean"); b.Property("UserName") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.HasKey("Id"); @@ -269,24 +278,24 @@ namespace NexusReader.Infrastructure.Migrations { b.Property("ContentHash") .HasMaxLength(64) - .HasColumnType("TEXT"); + .HasColumnType("character varying(64)"); b.Property("CreatedAt") - .HasColumnType("TEXT"); + .HasColumnType("timestamp with time zone"); b.Property("JsonData") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ModelId") .IsRequired() .HasMaxLength(50) - .HasColumnType("TEXT"); + .HasColumnType("character varying(50)"); b.Property("PromptVersion") .IsRequired() .HasMaxLength(10) - .HasColumnType("TEXT"); + .HasColumnType("character varying(10)"); b.HasKey("ContentHash"); diff --git a/src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.cs b/src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.cs similarity index 68% rename from src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.cs rename to src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.cs index 27ef8da..db42422 100644 --- a/src/NexusReader.Infrastructure/Migrations/20260428142027_InitialIdentityAndEbooks.cs +++ b/src/NexusReader.Infrastructure/Migrations/20260428184727_InitialPostgres.cs @@ -1,12 +1,13 @@ using System; using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable namespace NexusReader.Infrastructure.Migrations { /// - public partial class InitialIdentityAndEbooks : Migration + public partial class InitialPostgres : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -15,10 +16,10 @@ namespace NexusReader.Infrastructure.Migrations name: "AspNetRoles", columns: table => new { - Id = table.Column(type: "TEXT", nullable: false), - Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), - NormalizedName = table.Column(type: "TEXT", maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) }, constraints: table => { @@ -29,25 +30,25 @@ namespace NexusReader.Infrastructure.Migrations name: "AspNetUsers", columns: table => new { - Id = table.Column(type: "TEXT", nullable: false), - AITokenLimit = table.Column(type: "INTEGER", nullable: false), - AITokensUsed = table.Column(type: "INTEGER", nullable: false), - TenantId = table.Column(type: "TEXT", nullable: false), - CurrentPlan = table.Column(type: "TEXT", nullable: false), - UserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), - NormalizedUserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), - Email = table.Column(type: "TEXT", maxLength: 256, nullable: true), - NormalizedEmail = table.Column(type: "TEXT", maxLength: 256, nullable: true), - EmailConfirmed = table.Column(type: "INTEGER", nullable: false), - PasswordHash = table.Column(type: "TEXT", nullable: true), - SecurityStamp = table.Column(type: "TEXT", nullable: true), - ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), - PhoneNumber = table.Column(type: "TEXT", nullable: true), - PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), - TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), - LockoutEnd = table.Column(type: "TEXT", nullable: true), - LockoutEnabled = table.Column(type: "INTEGER", nullable: false), - AccessFailedCount = table.Column(type: "INTEGER", nullable: false) + Id = table.Column(type: "text", nullable: false), + AITokenLimit = table.Column(type: "integer", nullable: false), + AITokensUsed = table.Column(type: "integer", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + CurrentPlan = table.Column(type: "text", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) }, constraints: table => { @@ -58,11 +59,11 @@ namespace NexusReader.Infrastructure.Migrations name: "SemanticKnowledgeCache", columns: table => new { - ContentHash = table.Column(type: "TEXT", maxLength: 64, nullable: false), - JsonData = table.Column(type: "TEXT", nullable: false), - ModelId = table.Column(type: "TEXT", maxLength: 50, nullable: false), - PromptVersion = table.Column(type: "TEXT", maxLength: 10, nullable: false), - CreatedAt = table.Column(type: "TEXT", nullable: false) + ContentHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + JsonData = table.Column(type: "text", nullable: false), + ModelId = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + PromptVersion = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) }, constraints: table => { @@ -73,11 +74,11 @@ namespace NexusReader.Infrastructure.Migrations name: "AspNetRoleClaims", columns: table => new { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - RoleId = table.Column(type: "TEXT", nullable: false), - ClaimType = table.Column(type: "TEXT", nullable: true), - ClaimValue = table.Column(type: "TEXT", nullable: true) + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) }, constraints: table => { @@ -94,11 +95,11 @@ namespace NexusReader.Infrastructure.Migrations name: "AspNetUserClaims", columns: table => new { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - UserId = table.Column(type: "TEXT", nullable: false), - ClaimType = table.Column(type: "TEXT", nullable: true), - ClaimValue = table.Column(type: "TEXT", nullable: true) + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) }, constraints: table => { @@ -115,10 +116,10 @@ namespace NexusReader.Infrastructure.Migrations name: "AspNetUserLogins", columns: table => new { - LoginProvider = table.Column(type: "TEXT", nullable: false), - ProviderKey = table.Column(type: "TEXT", nullable: false), - ProviderDisplayName = table.Column(type: "TEXT", nullable: true), - UserId = table.Column(type: "TEXT", nullable: false) + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false) }, constraints: table => { @@ -135,8 +136,8 @@ namespace NexusReader.Infrastructure.Migrations name: "AspNetUserRoles", columns: table => new { - UserId = table.Column(type: "TEXT", nullable: false), - RoleId = table.Column(type: "TEXT", nullable: false) + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false) }, constraints: table => { @@ -159,10 +160,10 @@ namespace NexusReader.Infrastructure.Migrations name: "AspNetUserTokens", columns: table => new { - UserId = table.Column(type: "TEXT", nullable: false), - LoginProvider = table.Column(type: "TEXT", nullable: false), - Name = table.Column(type: "TEXT", nullable: false), - Value = table.Column(type: "TEXT", nullable: true) + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) }, constraints: table => { @@ -179,14 +180,14 @@ namespace NexusReader.Infrastructure.Migrations name: "Ebooks", columns: table => new { - Id = table.Column(type: "TEXT", nullable: false), - Title = table.Column(type: "TEXT", maxLength: 255, nullable: false), - Author = table.Column(type: "TEXT", maxLength: 255, nullable: false), - FilePath = table.Column(type: "TEXT", nullable: false), - CoverUrl = table.Column(type: "TEXT", nullable: true), - AddedDate = table.Column(type: "TEXT", nullable: false), - LastReadDate = table.Column(type: "TEXT", nullable: true), - UserId = table.Column(type: "TEXT", nullable: false) + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + Author = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + FilePath = table.Column(type: "text", nullable: false), + CoverUrl = table.Column(type: "text", nullable: true), + AddedDate = table.Column(type: "timestamp with time zone", nullable: false), + LastReadDate = table.Column(type: "timestamp with time zone", nullable: true), + UserId = table.Column(type: "text", nullable: false) }, constraints: table => { diff --git a/src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.Designer.cs b/src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.Designer.cs new file mode 100644 index 0000000..4072051 --- /dev/null +++ b/src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.Designer.cs @@ -0,0 +1,377 @@ +// +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; + +#nullable disable + +namespace NexusReader.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260428185239_IncreaseHashLength")] + partial class IncreaseHashLength + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + 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 without 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 without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Ebooks"); + }); + + 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("CurrentPlan") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + 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("TenantId") + .HasColumnType("uuid"); + + 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.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b => + { + b.Property("ContentHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PromptVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("ContentHash"); + + b.HasIndex("ContentHash") + .IsUnique(); + + b.ToTable("SemanticKnowledgeCache"); + }); + + 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.NexusUser", b => + { + b.Navigation("Ebooks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.cs b/src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.cs new file mode 100644 index 0000000..331c6ea --- /dev/null +++ b/src/NexusReader.Infrastructure/Migrations/20260428185239_IncreaseHashLength.cs @@ -0,0 +1,89 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusReader.Infrastructure.Migrations +{ + /// + public partial class IncreaseHashLength : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "SemanticKnowledgeCache", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "ContentHash", + table: "SemanticKnowledgeCache", + type: "character varying(128)", + maxLength: 128, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + 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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "SemanticKnowledgeCache", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "ContentHash", + table: "SemanticKnowledgeCache", + type: "character varying(64)", + maxLength: 64, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldMaxLength: 128); + + 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"); + } + } +} diff --git a/src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.Designer.cs b/src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.Designer.cs new file mode 100644 index 0000000..850470c --- /dev/null +++ b/src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.Designer.cs @@ -0,0 +1,420 @@ +// +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; + +#nullable disable + +namespace NexusReader.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260429080302_AddQuizResults")] + partial class AddQuizResults + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + 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 without 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 without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Ebooks"); + }); + + 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("CurrentPlan") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + 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("TenantId") + .HasColumnType("uuid"); + + 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.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalQuestions") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + 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 without time zone"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PromptVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("ContentHash"); + + b.HasIndex("ContentHash") + .IsUnique(); + + b.ToTable("SemanticKnowledgeCache"); + }); + + 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.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.NexusUser", b => + { + b.Navigation("Ebooks"); + + b.Navigation("QuizResults"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.cs b/src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.cs new file mode 100644 index 0000000..16d9a39 --- /dev/null +++ b/src/NexusReader.Infrastructure/Migrations/20260429080302_AddQuizResults.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusReader.Infrastructure.Migrations +{ + /// + public partial class AddQuizResults : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "QuizResults", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "text", nullable: false), + Topic = table.Column(type: "text", nullable: false), + Score = table.Column(type: "integer", nullable: false), + TotalQuestions = table.Column(type: "integer", nullable: false), + CompletedDate = table.Column(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_QuizResults", x => x.Id); + table.ForeignKey( + name: "FK_QuizResults_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_QuizResults_UserId", + table: "QuizResults", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "QuizResults"); + } + } +} diff --git a/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 84969d4..3ef6c73 100644 --- a/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusReader.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using NexusReader.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -15,24 +16,28 @@ namespace NexusReader.Infrastructure.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ConcurrencyStamp") .IsConcurrencyToken() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("Name") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.Property("NormalizedName") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.HasKey("Id"); @@ -47,17 +52,19 @@ namespace NexusReader.Infrastructure.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("ClaimType") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ClaimValue") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("RoleId") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("Id"); @@ -70,17 +77,19 @@ namespace NexusReader.Infrastructure.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("ClaimType") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ClaimValue") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("UserId") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("Id"); @@ -92,17 +101,17 @@ namespace NexusReader.Infrastructure.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ProviderKey") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ProviderDisplayName") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("UserId") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("LoginProvider", "ProviderKey"); @@ -114,10 +123,10 @@ namespace NexusReader.Infrastructure.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("RoleId") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("UserId", "RoleId"); @@ -129,16 +138,16 @@ namespace NexusReader.Infrastructure.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { b.Property("UserId") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("LoginProvider") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("Name") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("Value") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("UserId", "LoginProvider", "Name"); @@ -149,34 +158,34 @@ namespace NexusReader.Infrastructure.Migrations { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); + .HasColumnType("uuid"); b.Property("AddedDate") - .HasColumnType("TEXT"); + .HasColumnType("timestamp without time zone"); b.Property("Author") .IsRequired() .HasMaxLength(255) - .HasColumnType("TEXT"); + .HasColumnType("character varying(255)"); b.Property("CoverUrl") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("FilePath") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("LastReadDate") - .HasColumnType("TEXT"); + .HasColumnType("timestamp without time zone"); b.Property("Title") .IsRequired() .HasMaxLength(255) - .HasColumnType("TEXT"); + .HasColumnType("character varying(255)"); b.Property("UserId") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.HasKey("Id"); @@ -188,67 +197,67 @@ namespace NexusReader.Infrastructure.Migrations modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => { b.Property("Id") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("AITokenLimit") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.Property("AITokensUsed") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.Property("AccessFailedCount") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.Property("ConcurrencyStamp") .IsConcurrencyToken() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("CurrentPlan") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("Email") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.Property("EmailConfirmed") - .HasColumnType("INTEGER"); + .HasColumnType("boolean"); b.Property("LockoutEnabled") - .HasColumnType("INTEGER"); + .HasColumnType("boolean"); b.Property("LockoutEnd") - .HasColumnType("TEXT"); + .HasColumnType("timestamp with time zone"); b.Property("NormalizedEmail") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.Property("NormalizedUserName") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.Property("PasswordHash") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("PhoneNumber") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("PhoneNumberConfirmed") - .HasColumnType("INTEGER"); + .HasColumnType("boolean"); b.Property("SecurityStamp") - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("TenantId") - .HasColumnType("TEXT"); + .HasColumnType("uuid"); b.Property("TwoFactorEnabled") - .HasColumnType("INTEGER"); + .HasColumnType("boolean"); b.Property("UserName") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.HasKey("Id"); @@ -262,28 +271,58 @@ namespace NexusReader.Infrastructure.Migrations b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalQuestions") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("QuizResults"); + }); + modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b => { b.Property("ContentHash") - .HasMaxLength(64) - .HasColumnType("TEXT"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); b.Property("CreatedAt") - .HasColumnType("TEXT"); + .HasColumnType("timestamp without time zone"); b.Property("JsonData") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("text"); b.Property("ModelId") .IsRequired() .HasMaxLength(50) - .HasColumnType("TEXT"); + .HasColumnType("character varying(50)"); b.Property("PromptVersion") .IsRequired() .HasMaxLength(10) - .HasColumnType("TEXT"); + .HasColumnType("character varying(10)"); b.HasKey("ContentHash"); @@ -355,9 +394,22 @@ namespace NexusReader.Infrastructure.Migrations b.Navigation("User"); }); + 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.NexusUser", b => { b.Navigation("Ebooks"); + + b.Navigation("QuizResults"); }); #pragma warning restore 612, 618 } diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index d388937..9ea0cb8 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -15,8 +15,10 @@ + + diff --git a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs index 4ecd582..7bc9006 100644 --- a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs +++ b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs @@ -12,6 +12,7 @@ public class AppDbContext : IdentityDbContext public DbSet SemanticKnowledgeCache => Set(); public DbSet Ebooks => Set(); + public DbSet QuizResults => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -30,5 +31,13 @@ public class AppDbContext : IdentityDbContext .HasForeignKey(e => e.UserId) .OnDelete(DeleteBehavior.Cascade); }); + + modelBuilder.Entity(entity => + { + entity.HasOne(e => e.User) + .WithMany(u => u.QuizResults) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/src/NexusReader.Infrastructure/Services/BillingService.cs b/src/NexusReader.Infrastructure/Services/BillingService.cs new file mode 100644 index 0000000..ca47f5c --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/BillingService.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Domain.Entities; +using NexusReader.Infrastructure.Persistence; + +namespace NexusReader.Infrastructure.Services; + +public class BillingService : IBillingService +{ + private readonly AppDbContext _dbContext; + private readonly UserManager _userManager; + + public BillingService(AppDbContext dbContext, UserManager userManager) + { + _dbContext = dbContext; + _userManager = userManager; + } + + public async Task HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId) + { + var user = await _userManager.FindByEmailAsync(customerEmail); + if (user == null) return false; + + // Map Stripe Product IDs to Nexus Plans + // These IDs would typically come from configuration + if (stripeProductId.Contains("pro")) + { + user.CurrentPlan = "Pro"; + user.AITokenLimit = 50000; + } + else if (stripeProductId.Contains("basic")) + { + user.CurrentPlan = "Basic"; + user.AITokenLimit = 10000; + } + + await _userManager.UpdateAsync(user); + return true; + } + + public async Task HandleSubscriptionDeletedAsync(string customerEmail) + { + var user = await _userManager.FindByEmailAsync(customerEmail); + if (user == null) return false; + + user.CurrentPlan = "Free"; + user.AITokenLimit = 1000; // Reset to free limit + + await _userManager.UpdateAsync(user); + return true; + } +} diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs index 4fd5782..8555e71 100644 --- a/src/NexusReader.Maui/MauiProgram.cs +++ b/src/NexusReader.Maui/MauiProgram.cs @@ -9,41 +9,44 @@ public static class MauiProgram { public static MauiApp CreateMauiApp() { - var builder = MauiApp.CreateBuilder(); - builder - .UseMauiApp() - .ConfigureFonts(fonts => - { - fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); - }); + try + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp(); - builder.Services.AddMauiBlazorWebView(); + builder.Services.AddMauiBlazorWebView(); #if DEBUG - builder.Services.AddBlazorWebViewDeveloperTools(); - builder.Logging.AddDebug(); + builder.Services.AddBlazorWebViewDeveloperTools(); + builder.Logging.AddDebug(); #endif - // Infrastructure - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - - // Identity - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(sp => - sp.GetRequiredService()); - builder.Services.AddCascadingAuthenticationState(); - builder.Services.AddAuthorizationCore(); + // Minimal Infrastructure + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Minimal Identity (Safe Mode) + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => + sp.GetRequiredService()); + builder.Services.AddAuthorizationCore(); - // Network - builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://localhost:5000") }); // Update with real API URL later + // Basic Network + builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://10.0.2.2:5000") }); - // Shared UI State - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); + // UI State + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); - return builder.Build(); + return builder.Build(); + } + catch (Exception ex) + { + // This might help the debugger catch the exception more reliably + System.Diagnostics.Debug.WriteLine($"MAUI Startup Error: {ex}"); + throw; + } } } diff --git a/src/NexusReader.Maui/Platforms/Android/AndroidManifest.xml b/src/NexusReader.Maui/Platforms/Android/AndroidManifest.xml new file mode 100644 index 0000000..08391fd --- /dev/null +++ b/src/NexusReader.Maui/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/NexusReader.Maui/Platforms/Android/MainActivity.cs b/src/NexusReader.Maui/Platforms/Android/MainActivity.cs new file mode 100644 index 0000000..c1f294f --- /dev/null +++ b/src/NexusReader.Maui/Platforms/Android/MainActivity.cs @@ -0,0 +1,19 @@ +using Android.App; +using Android.Content.PM; +using Android.OS; +using Android.Util; + +namespace NexusReader.Maui; + +[Activity(Theme = "@style/Maui.MainTheme.NoActionBar", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +public class MainActivity : MauiAppCompatActivity +{ + private const string Tag = "NEXUS_MAUI"; + + protected override void OnCreate(Bundle? savedInstanceState) + { + Log.Debug(Tag, "MainActivity.OnCreate starting..."); + base.OnCreate(savedInstanceState); + Log.Debug(Tag, "MainActivity.OnCreate completed"); + } +} diff --git a/src/NexusReader.Maui/Platforms/Android/MainApplication.cs b/src/NexusReader.Maui/Platforms/Android/MainApplication.cs new file mode 100644 index 0000000..df00cd2 --- /dev/null +++ b/src/NexusReader.Maui/Platforms/Android/MainApplication.cs @@ -0,0 +1,34 @@ +using Android.App; +using Android.Runtime; +using Android.Util; + +namespace NexusReader.Maui; + +[Application] +public class MainApplication : MauiApplication +{ + private const string Tag = "NEXUS_MAUI"; + + public MainApplication(IntPtr handle, JniHandleOwnership ownership) + : base(handle, ownership) + { + Log.Debug(Tag, "MainApplication constructor called"); + } + + protected override MauiApp CreateMauiApp() + { + Log.Debug(Tag, "CreateMauiApp starting..."); + try + { + var app = MauiProgram.CreateMauiApp(); + Log.Debug(Tag, "CreateMauiApp successful"); + return app; + } + catch (Exception ex) + { + Log.Error(Tag, $"CreateMauiApp FAILED: {ex.Message}"); + Log.Error(Tag, ex.StackTrace); + throw; + } + } +} diff --git a/src/NexusReader.Maui/Platforms/Android/Resources/values/colors.xml b/src/NexusReader.Maui/Platforms/Android/Resources/values/colors.xml new file mode 100644 index 0000000..dc882e7 --- /dev/null +++ b/src/NexusReader.Maui/Platforms/Android/Resources/values/colors.xml @@ -0,0 +1,6 @@ + + + #512BD4 + #2B0B98 + #DFD8F7 + diff --git a/src/NexusReader.Maui/Platforms/Android/Resources/values/styles.xml b/src/NexusReader.Maui/Platforms/Android/Resources/values/styles.xml new file mode 100644 index 0000000..cb326cd --- /dev/null +++ b/src/NexusReader.Maui/Platforms/Android/Resources/values/styles.xml @@ -0,0 +1,13 @@ + + + + diff --git a/src/NexusReader.Maui/Resources/Styles/Styles.xaml b/src/NexusReader.Maui/Resources/Styles/Styles.xaml index d3a69c9..b138360 100644 --- a/src/NexusReader.Maui/Resources/Styles/Styles.xaml +++ b/src/NexusReader.Maui/Resources/Styles/Styles.xaml @@ -6,7 +6,7 @@ diff --git a/src/NexusReader.Maui/Services/MauiStorageService.cs b/src/NexusReader.Maui/Services/MauiStorageService.cs new file mode 100644 index 0000000..0578ad3 --- /dev/null +++ b/src/NexusReader.Maui/Services/MauiStorageService.cs @@ -0,0 +1,110 @@ +using FluentResults; +using Microsoft.Maui.Storage; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Maui.Services; + +public class MauiStorageService : INativeStorageService +{ + public Result SaveString(string key, string value) + { + try + { + Preferences.Default.Set(key, value); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result GetString(string key) + { + try + { + return Result.Ok(Preferences.Default.Get(key, null)); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result SaveBool(string key, bool value) + { + try + { + Preferences.Default.Set(key, value); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result GetBool(string key, bool defaultValue = false) + { + try + { + return Result.Ok(Preferences.Default.Get(key, defaultValue)); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result Remove(string key) + { + try + { + Preferences.Default.Remove(key); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public async Task SaveSecureString(string key, string value) + { + try + { + await SecureStorage.Default.SetAsync(key, value); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public async Task> GetSecureString(string key) + { + try + { + var value = await SecureStorage.Default.GetAsync(key); + return Result.Ok(value); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result RemoveSecure(string key) + { + try + { + SecureStorage.Default.Remove(key); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } +} diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor index face0e9..cbbb043 100644 --- a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor @@ -28,6 +28,18 @@ case "trash": break; + case "mail": + + break; + case "lock": + + break; + case "eye": + + break; + case "eye-off": + + break; default: diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index 1e77c90..e1a7a4f 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -66,7 +66,7 @@ await LoadChapterAsync(NavigationService.CurrentChapterIndex); } - private async void OnNavigationChanged() + private async Task OnNavigationChanged() { _isJsInitialized = false; _selectedText = string.Empty; @@ -166,7 +166,7 @@ NavigationService.UpdateMetadata(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle); // Trigger full page graph generation after loading - _ = Coordinator.ProcessFullPageAsync(GetFullPageContent()); + await Coordinator.ProcessFullPageAsync(GetFullPageContent()); } else { diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderFooter.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderFooter.razor index 5ff69c7..9576000 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderFooter.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderFooter.razor @@ -33,7 +33,13 @@ @code { protected override void OnInitialized() { - NavigationService.OnNavigationChanged += StateHasChanged; + NavigationService.OnNavigationChanged += HandleNavigationChanged; + } + + private Task HandleNavigationChanged() + { + StateHasChanged(); + return Task.CompletedTask; } private int CalculateProgress() @@ -44,6 +50,6 @@ public void Dispose() { - NavigationService.OnNavigationChanged -= StateHasChanged; + NavigationService.OnNavigationChanged -= HandleNavigationChanged; } } diff --git a/src/NexusReader.UI.Shared/Components/RedirectToLogin.razor b/src/NexusReader.UI.Shared/Components/RedirectToLogin.razor new file mode 100644 index 0000000..0d30b1e --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/RedirectToLogin.razor @@ -0,0 +1,16 @@ +@inject NavigationManager NavigationManager + +@code { + protected override void OnInitialized() + { + var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); + if (string.IsNullOrWhiteSpace(returnUrl)) + { + NavigationManager.NavigateTo("/account/login"); + } + else + { + NavigationManager.NavigateTo($"/account/login?returnUrl={Uri.EscapeDataString(returnUrl)}"); + } + } +} diff --git a/src/NexusReader.UI.Shared/Layout/AuthLayout.razor b/src/NexusReader.UI.Shared/Layout/AuthLayout.razor new file mode 100644 index 0000000..9085127 --- /dev/null +++ b/src/NexusReader.UI.Shared/Layout/AuthLayout.razor @@ -0,0 +1,24 @@ +@inherits LayoutComponentBase + +
+ + @Body +
+ + diff --git a/src/NexusReader.UI.Shared/Layout/MainLayout.razor b/src/NexusReader.UI.Shared/Layout/MainLayout.razor index 6392142..a53e1e3 100644 --- a/src/NexusReader.UI.Shared/Layout/MainLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/MainLayout.razor @@ -21,36 +21,33 @@ -
- -
-
-
- - Asystent AI -
- - - + + +
+ +
+
+
+ + Asystent AI +
+ - - - - - - + +
+ +
+ + +
+
- -
- - -
-
-
+ +
diff --git a/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj b/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj index 7dcc1a6..ba3e4a7 100644 --- a/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj +++ b/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + true diff --git a/src/NexusReader.UI.Shared/Pages/Account/Login.razor b/src/NexusReader.UI.Shared/Pages/Account/Login.razor index 4006e4a..e006433 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Login.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Login.razor @@ -1,69 +1,83 @@ @page "/account/login" +@layout AuthLayout +@attribute [AllowAnonymous] @using Microsoft.AspNetCore.Components.Forms @using NexusReader.UI.Shared.Services +@using NexusReader.UI.Shared.Components.Atoms @inject IIdentityService IdentityService @inject NavigationManager NavigationManager -