6 Commits

33 changed files with 1709 additions and 523 deletions
@@ -8,6 +8,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy
- `NexusReader.Domain`: Enterprise business rules (Entities, Value Objects, Domain Events).
- `NexusReader.Application`: Application business rules (Commands, Queries, DTOs, Mappings, Interfaces).
- `NexusReader.Infrastructure`: Data access, external services, and platform-specific implementations.
- **Persistence**: Use `IDbContextFactory<AppDbContext>` for long-running operations or when multiple units of work are needed in a single scope (especially in Blazor).
- `NexusReader.UI.Shared`: UI logic and Blazor components.
- `NexusReader.Maui` / `NexusReader.Web`: Platform host projects.
-87
View File
@@ -1,87 +0,0 @@
# 📑 Project Backlog: Nexus AI E-Reader (Mockup Implementation)
**Architecture Framework:** .NET 10 | Blazor Component Model | CQRS with MediatR | FluentResult | Mapster
---
## 🟢 PHASE 1: Infrastructure & Design Tokens
*Goal: Prepare the clean architecture foundation and the visual DNA.*
### [TASK-01] Solution & Directory Structure Setup
- **Action:** Create the folder structure: `/src` for projects, `/tests` for unit/integration tests.
- **Details:** Initialize `NexusReader.Web` (Blazor), `NexusReader.Application`, `NexusReader.Domain`, and `NexusReader.Infrastructure`.
- **DoD:** Solution compiles with strict folder separation.
### [TASK-02] Core Library Integration
- **Action:** Install and configure LuckyPennySoftware.MediatR, Mapster, and FluentResult.
- **Details:** - Setup `MappingConfig` for Mapster in the Application layer.
- Implement a `BaseHandler` that returns `Result<T>`.
- **DoD:** A sample Query returns a `Success` Result via MediatR.
### [TASK-03] Nexus Neon Design System
- **Action:** Implement global CSS variables in `app.css` and base Atoms.
- **Details:** - Variables: `--nexus-neon: #00ff99`, `--nexus-bg: #121212`, `--nexus-card: #1e1e1e`.
- Components: `NexusButton.razor`, `NexusTypography.razor` (handling Serif for ebook, Sans for UI).
- **DoD:** Variables are accessible via all scoped CSS files.
---
## 🔵 PHASE 2: Seamless Reader & AI Assistant (Left Side / Inline)
*Goal: Implement the ebook reading logic and the "Vertical Flow" AI injection.*
### [TASK-04] ReaderCanvas & Dynamic Content Injection
- **Action:** Create `ReaderCanvas.razor` to render ebook text.
- **Details:** - Logic to split text into blocks.
- Implementation of an "Injection Point" system where `AiAssistantBubble.razor` can be rendered inline between paragraphs.
- **Mockup Match:** Text must use the high-contrast Serif font from the mockup.
### [TASK-05] AiAssistantBubble Component
- **Action:** Implement the AI chat bubble with a robot avatar.
- **Details:** - Scoped CSS for the glowing border and dark glassmorphism background.
- Parameters for `DialogueText` and `ActionButtons` ("Pokaż więcej", "Rozwiąż quiz").
- Integration with Semantic Kernel for streaming text.
- **DoD:** Bubble appears smoothly in the text flow without absolute positioning.
---
## 🟡 PHASE 3: Knowledge Graph & Brain (Right Side / Flow)
*Goal: Implement the D3.js graph and the "You are here" logic.*
### [TASK-06] D3.js Knowledge Graph Bridge
- **Action:** Implement `KnowledgeGraph.razor` with JS Interop.
- **Details:** - ES6 Module `knowledgeGraph.js` using D3.js v7.
- SVG ViewBox scaling for portrait orientation.
- Implementation of the "TU JESTEŚ" (You Are Here) pulsing label on the active node.
- **DoD:** Clicking a node in JS triggers a C# EventCallback via `DotNetObjectReference`.
### [TASK-07] Semantic Mapping Service
- **Action:** Create the `GetKnowledgeGraphQuery` (CQRS).
- **Details:** - Service uses Semantic Kernel to extract nodes from the current chapter.
- Mapster maps the AI raw response to the `GraphViewModel`.
- **DoD:** Graph updates dynamically when the reader moves to a new chapter.
---
## 🟠 PHASE 4: Verification & Mobile Polish
*Goal: Implement the quiz module and cross-platform readiness.*
### [TASK-08] KnowledgeCheck (Quiz) Module
- **Action:** Implement the `SubmitAnswerCommand` using MediatR.
- **Details:** - UI: `KnowledgeCheck.razor` with radio buttons and a "Wyślij" (Submit) button.
- Logic: Handler returns `Result` (Success/Failure) via FluentResult.
- Mapster: Map `QuizDto` to `QuizViewModel`.
- **Mockup Match:** Neon highlight on the selected/correct answer.
### [TASK-09] Persistence & Cross-Platform (Hybrid)
- **Action:** Implement `IPlatformService` for Android/iOS support.
- **Details:** - Safe-area-insets implementation for notches.
- `BrowserStorage` implementation of `AppState` to save progress.
- Haptic feedback abstraction (trigger vibration on correct answer).
- **DoD:** App maintains graph state after a manual refresh.
---
## 🛠️ Instructions for NexusArchitect
1. **Vertical Flow Priority:** Ensure that the AI assistant and the Graph never overlay text. Use `Flexbox` or `Grid` for a single, continuous scrollable column in portrait mode.
2. **Result Pattern:** Every single Mediator Handler **must** return `FluentResults.Result`.
3. **Mapster:** Perform all DTO-to-UI mappings in the Query/Command Handlers, not in the Razor components.
4. **Isolated Styles:** All specific CSS for the Neon effect must be in `.razor.css` files.
+7
View File
@@ -6,12 +6,14 @@ version: 1.0
# Agent Personas
## 👤 NexusArchitect
**Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor)
**Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI".
---
## 🏗️ Architecture Philosophy
- **Clean Architecture:** Strict separation of concerns. `Domain` -> `Application` <- `Infrastructure`.
- **CQRS Pattern:** Mandatory use of `MediatR`. Logic belongs in handlers, not UI components.
- **Result Pattern:** Zero exceptions for flow control. All handlers return `Result<T>` via `FluentResult`.
@@ -20,6 +22,7 @@ version: 1.0
---
## 🛠️ Technical Constraints
>
> [!IMPORTANT]
> **Zero Tolerance for `async void`**
> All async operations must return `Task` or `ValueTask`. Event handlers must use `Func<Task>` or async-compatible patterns.
@@ -31,6 +34,7 @@ version: 1.0
---
## 🧪 Development Workflow
1. **Verification-Led:** Plan and define tests/verification steps *before* writing feature code.
2. **Step-by-Step Execution:** Break complex tasks into manageable, verifiable chunks.
3. **Layer Integrity:** Always check for illegal cross-layer dependencies (e.g., Application depending on Infrastructure).
@@ -40,3 +44,6 @@ version: 1.0
> **Build command:** `dotnet build NexusReader.slnx --no-restore`
> Run from the solution root `/home/mjasin/Projekty/ejajBook`. Build warnings are acceptable; errors are not.
> [!IMPORTANT]
> **Git Workflow & Integration**
> All tasks originating from the repository must be performed on a separate branch. To connect to the Git repository, use the `gitea` MCP server.
-87
View File
@@ -1,87 +0,0 @@
# 🤖 LLM Agent Implementation Backlog: AI Semantic Integration
**Project Context:** .NET 10, EF Core (SQLite), `Microsoft.Extensions.AI`, [`GeminiDotnet`](https://github.com/rabuckley/GeminiDotnet).
**Core Goal:** Integrate Gemini 1.5 Flash with a persistent Semantic Cache to minimize API costs and latency.
---
## 🏗️ Phase 1: Persistence & Domain Layer
**Objective:** Define the storage schema to prevent redundant AI calls.
### Task 1.1: Create `SemanticKnowledgeCache` Entity
* **Target Folder:** `Core/Entities` or `Infrastructure/Persistence/Entities`.
* **Requirements:**
* Create a class `SemanticKnowledgeCache`.
* **Properties:**
* `string ContentHash` (Key, Fixed length 64).
* `string JsonData` (Required, stores the serialized AI output).
* `string ModelId` (Default: "gemini-1.5-flash").
* `string PromptVersion` (Default: "1.0").
* `DateTime CreatedAt` (UTC).
* **LLM Instructions:** "Generate an EF Core entity for SemanticKnowledgeCache. Ensure `ContentHash` has a Unique Index for O(1) lookups."
### Task 1.2: Implement Hashing Utility
* **Target Folder:** `Core/Helpers` or `Infrastructure/Security`.
* **Requirements:**
* Create `ContentHasher` class.
* Method `string ComputeHash(string input)`.
* **Logic:** Normalize input (Trim, lower-case) -> Compute SHA-256 -> Return Hex string.
* **LLM Instructions:** "Create a thread-safe utility to generate SHA-256 hashes from strings. Ensure it handles nulls and whitespace consistently."
---
## 🧠 Phase 2: AI Client & Contract Definition
**Objective:** Set up the communication bridge with Google Gemini API using [`GeminiDotnet`](https://github.com/rabuckley/GeminiDotnet).
### Task 2.1: Define Data Transfer Objects (DTOs)
* **Target Folder:** `NexusReader.Application/DTOs/AI`.
* **Requirements:**
* Define `KnowledgePacket` record containing `List<KeyConcept>` and `List<QuizQuestion>`.
* Use `[JsonPropertyName]` attributes for strict JSON mapping.
* **LLM Instructions:** "Define immutable records for the AI response schema. Ensure they match the expected JSON structure from the system prompt."
### Task 2.2: Infrastructure AI Client Setup
* **Target:** `NexusReader.Infrastructure/DependencyInjection.cs`.
* **Requirements:**
* Install `Microsoft.Extensions.AI` and `GeminiDotnet.Extensions.AI`.
* Register `IChatClient` using `GeminiChatClient`.
* Inject `ApiKey` from `IConfiguration`.
* **LLM Instructions:** "Register the `GeminiChatClient` in the DI container. Use the .NET 10 `AddChatClient` extension pattern."
---
## ⚙️ Phase 3: Service Orchestration (The "Smart" Logic)
**Objective:** Implement the caching proxy logic.
### Task 3.1: Create `KnowledgeService` Implementation
* **Target Folder:** `Application/Services`.
* **Logic Flow:**
1. `hash = ContentHasher.ComputeHash(inputText)`.
2. `cached = await dbContext.Cache.FirstOrDefaultAsync(h => h.ContentHash == hash)`.
3. If `cached` exists AND `PromptVersion` matches -> Deserialize and return.
4. Else -> Call `IChatClient.CompleteAsync<KnowledgePacket>(...)`.
5. Save result to DB with the hash -> Return.
* **LLM Instructions:** "Implement a service that acts as a proxy between the UI and the Gemini API. It must prioritize SQLite cache hits over API calls."
### Task 3.2: System Prompt Engineering
* **Requirements:**
* Create a `PromptRegistry` class.
* **System Message:** "You are an educational assistant. Analyze the text and output ONLY valid minified JSON. Schema: { 'concepts': [], 'quizzes': [] }. Do not include markdown formatting like \` \` \` json."
* **LLM Instructions:** "Craft a high-precision system prompt for Gemini 1.5 Flash to ensure it returns parseable JSON without unnecessary tokens."
---
## 🛡️ Phase 4: Resilience & Optimization
**Objective:** Handle API limits and monitor performance.
### Task 4.1: Resilience Pipeline (Polly)
* **Requirements:**
* Implement an `HttpRetry` policy specifically for `429 Too Many Requests`.
* Use Exponential Backoff with Jitter.
* **LLM Instructions:** "Add a resilience pipeline to the AI client using Polly. Handle rate-limiting gracefully to stay within the Gemini Free Tier limits."
### Task 4.2: Request Pre-processing (Token Saving)
* **Logic:**
* Check input string length.
* If `length > threshold`, truncate or throw an error to prevent massive token spend.
* **LLM Instructions:** "Add a guard clause to the KnowledgeService to validate input size before calling the API. Log the estimated token count."
-156
View File
@@ -1,156 +0,0 @@
# 🔍 NexusReader Code Review Backlog
## 🔴 CRITICAL — Fix Before Next Release
- **Status:** ✅ Resolved (2026-05-03)
- **Implementation:** Removed `AddMediatR` from `AddApplication()` and `AddInfrastructure()`. Unified registration in Host (`Program.cs`, `MauiProgram.cs`). Added `IInfrastructureMarker` and a startup validation check in `Web.New` that throws `InvalidOperationException` if `AddInfrastructure` is missing.
- **DoD:** Application fails fast with a clear error if `AddInfrastructure()` is omitted.
---
- **Status:** ✅ Resolved (2026-05-03)
- **Implementation:** Added `VerifyGroundednessAsync` to `IKnowledgeService` and implemented it in `KnowledgeService` (Infrastructure). Updated `VerifyGroundednessCommandHandler` in Application to inject `IKnowledgeService` instead of `IChatClient`.
- **DoD:** No `IChatClient` or `IEmbeddingGenerator` references remain in `NexusReader.Application`.
---
- **Status:** ✅ Resolved (2026-05-03)
- **Implementation:** Threaded `tenantId` through all `IKnowledgeService` methods and `ProcessKnowledgeUnitsAsync`. Scoped `SemanticKnowledgeCache` and `KnowledgeUnit` lookups/writes to the provided `tenantId`. Updated API endpoints in `Program.cs` and `WasmKnowledgeService` to pass the authenticated user's `TenantId`.
- **DoD:** No hardcoded `"global"` TenantId in write paths. Extracted units are always scoped to the caller's tenant.
---
- **Status:** ✅ Resolved (2026-05-03)
- **Implementation:** Changed `NexusUser.TenantId` from `Guid` to `string`. All entities now use `string` for `TenantId`, allowing the use of `"global"` as a sentinel value.
- **DoD:** All entities use the same `TenantId` type. All query filters are consistent.
---
## 🟠 MAJOR — High Priority Fixes
### [MJ-01] Missing Exception Handling in `EpubService`
- **File:** `Infrastructure/Services/EpubService.cs:45`
- **Problem:** The service uses raw `ZipArchive` operations without try-catch blocks. Corrupt EPUB files will crash the circuit instead of returning a `Result.Fail`.
- **Action:** Wrap the extraction logic in a try-catch and return `Result.Fail<EpubContent>(ex.Message)`.
- **DoD:** Uploading a renamed `.txt` as `.epub` returns a user-friendly error instead of a 500 error.
---
### [MJ-02] Hardcoded Pricing & Limits in Stripe Logic
- **File:** `Web.New/Program.cs:298`
- **Problem:** Subscription limits (50k tokens for Pro) are hardcoded in the webhook handler. Changing prices or limits requires a code redeploy.
- **Action:** Move limits to `appsettings.json` or a `SubscriptionPlan` domain entity. Use `IOptions<SubscriptionSettings>` in the handler.
- **DoD:** Limits can be changed via configuration without rebuilding the app.
---
### [MJ-03] Knowledge Graph: Circular Dependency Potential
- **File:** `UI.Shared/Services/KnowledgeGraphService.cs`
- **Problem:** The service manages its own state but is injected as `Scoped`. If multiple components use it, they share the same graph state, which might lead to race conditions during navigation.
- **Action:** Ensure the service is either stateless (returning data) or implement a `Clear()` method called on `OnInitialized`.
- **DoD:** Navigating between two different books correctly clears the graph.
---
### [MJ-04] Insecure `Profile` Endpoint Exposes Internal IDs
- **File:** `Web.New/Program.cs:366`
- **Problem:** The `/identity/profile` endpoint returns the raw `TenantId` and internal database IDs in the JSON response.
- **Action:** Create a `UserProfileDto` and use Mapster to exclude internal metadata.
- **DoD:** Sensitive internal GUIDs/IDs are not visible in the browser's Network tab.
---
### [MJ-05] Missing Database Index for Multi-Tenancy
- **Problem:** `TenantId` is used in almost every query (KnowledgeUnits, Cache, Users) but lacks a database index. As data grows, retrieval will slow down significantly (O(N) vs O(log N)).
- **Action:** Add `HasIndex(x => x.TenantId)` to the `AppDbContext` configuration for all relevant entities.
- **DoD:** EF Migration generated with `CREATE INDEX` for `TenantId`.
---
### [MJ-06] KM-RAG: Link Integrity is Not Validated
- **File:** `Infrastructure/Services/KnowledgeService.cs:208`
- **Problem:** When processing `KnowledgeUnitLink`, the service assumes both `Source` and `Target` units exist in the DB. If AI returns a link to a non-existent node, the DB insert will fail (foreign key violation).
- **Action:** Add a check to verify both units exist or are being created in the same batch before adding the link.
- **DoD:** Broken links from AI are logged as warnings and skipped, not causing a total failure.
---
### [MJ-07] Ebook Entity Missing Tenant Isolation
- **File:** `Domain/Entities/Ebook.cs`
- **Problem:** The `Ebook` entity lacks a `TenantId` property. All uploaded books are visible to all users if the ID is guessed.
- **Action:** Add `TenantId` to `Ebook` and filter all queries in `EpubService`.
- **DoD:** User A cannot see User B's books.
---
### [MJ-08] QuizResults Missing Tenant Isolation
- **File:** `Domain/Entities/QuizResult.cs`
- **Problem:** Similar to ebooks, quiz results are not scoped to a tenant.
- **Action:** Add `TenantId` to `QuizResult`.
- **DoD:** Results are correctly partitioned.
---
## 🟡 MINOR — Technical Debt & UX
### [MN-01] Missing Logging in `KnowledgeCoordinator`
- **Action:** Add `ILogger<KnowledgeCoordinator>` and log successful/failed extraction steps.
### [MN-02] Hardcoded "Gemini-1.5-Flash" in Domain
- **File:** `Domain/Entities/SemanticKnowledgeCache.cs:20`
- **Action:** Move the default model ID to a constant in `AiSettings`.
### [MN-03] UI: Shimmer Effect Lack Animation
- **File:** `UI.Shared/Components/Molecules/GroundednessBadge.razor`
- **Action:** Add `@keyframes` for the shimmer effect in CSS.
### [MN-04] Identity: Google Callback Lack Error Handling
- **File:** `Web.New/Program.cs:340`
- **Action:** Better UI feedback when `ExternalLoginInfo` is null.
### [MN-05] Tokenizer Initialization is Expensive
- **File:** `Infrastructure/Services/KnowledgeService.cs:43`
- **Action:** Make `_tokenizer` static or Singleton to avoid recreating it per request.
### [MN-06] Mapster: Global Configuration Check
- **Action:** Ensure `TypeAdapterConfig.GlobalSettings.Scan(...)` is only called once.
### [MN-07] SignalR: Missing Reconnection Logic
- **Action:** Implement `hubConnection.OnReconnected` in `SyncService.cs`.
### [MN-08] CSS: Z-Index Consistency
- **Action:** Define a `z-index` scale in `index.css`.
### [MN-09] SEO: Missing Meta Descriptions
- **Action:** Update `App.razor` with dynamic meta tags.
### [MN-10] Performance: Large EPUB Parsing
- **Action:** Implement streaming extraction for EPUBs over 10MB.
---
## 🧪 TESTING — Coverage Gaps
### [TEST-01] Integration Tests for KM-RAG Retrieval
- **Action:** Create `tests/NexusReader.IntegrationTests`.
- **Scenario:** Ingest a document, then verify that `GetRelevantContext` returns the correct snippets with tenant isolation active.
---
## 📊 Summary Table
| Severity | Count | Status |
|---|---|---|
| 🔴 Critical | 4 | 4 resolved |
| 🟠 Major | 8 | Unresolved |
| 🟡 Minor | 10 | Unresolved |
| 🧪 Tests | 1 | Unresolved |
| **Total** | **23** | **4 resolved** |
-46
View File
@@ -1,46 +0,0 @@
# NexusArchitect - User Management Implementation Backlog
**Project:** AI-Powered E-book Reader SaaS
**Architecture:** .NET 10, Blazor Hybrid, MAUI, ASP.NET Core Identity
**Primary Goal:** Implement a secure, scalable authentication and authorization system with SaaS-specific features (AI token limits, subscription tiers).
---
## Phase 0: Backend Foundations (ASP.NET Core & EF Core)
| 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.<br>**AC:**<br>- [x] Properties added: `AITokenLimit` (int), `AITokensUsed` (int), `TenantId` (Guid), `CurrentPlan` (string).<br>- [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<NexusUser>`.<br>**AC:**<br>- [x] Mapped standard Identity tables (Users, Roles, Claims).<br>- [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.<br>**AC:**<br>- [x] SQL schema contains all 7+ standard Identity tables.<br>- [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`.<br>**AC:**<br>- [x] Endpoints `/register`, `/login`, and `/refresh` are active.<br>- [x] Verified functionality via Swagger/OpenAPI. | ASP.NET Core |
---
## Phase 1: Authentication & Authorization (UI & Logic)
| ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|:---|:---|:---|:---|
| **BACK-5** | Define Authorization Policies | **Description:** Implement Roles and Claims-based authorization (Free vs. Pro).<br>**AC:**<br>- [x] Created a `ProUser` policy.<br>- [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.<br>**AC:**<br>- [x] Theme: Dark mode with neon green accents.<br>- [x] Components: Email/Password fields, "Remember Me" toggle, "Login" button.<br>- [x] Integrates with `AuthenticationStateProvider`. | Blazor / CSS |
| **UI-2** | Google OAuth2 Integration | **Description:** Configure external login provider (Google) in the backend and UI.<br>**AC:**<br>- [x] Users can sign in via Google button.<br>- [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.<br>**AC:**<br>- [x] Validation: Email format, password complexity (min 8 chars, uppercase, digit).<br>- [x] Proper error handling for existing users. | Blazor |
---
## Phase 2: User Management & SaaS Scaling (Profile & Mobile)
| ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|:---|:---|:---|:---|
| **UI-4** | User Profile & Dashboard | **Description:** Build the User Profile UI focusing on "Active Learning" metrics.<br>**AC:**<br>- [x] Displays: Token usage bar (Used/Limit), average quiz score, and last read book.<br>- [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.<br>**AC:**<br>- [x] Securely store JWT tokens in `SecureStorage`.<br>- [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.<br>**AC:**<br>- [x] Refresh tokens implementation for mobile.<br>- [x] "Stay Signed In" functionality. | MAUI / Identity |
| **INTEG-1** | Stripe Subscription Webhooks | **Description:** Sync Identity Claims with Stripe subscription status.<br>**AC:**<br>- [x] Webhook updates `AITokenLimit` when a "Pro" plan is purchased.<br>- [x] User is downgraded back to "Free" limit upon cancellation. | Stripe SDK / .NET |
---
## Definition of Done (DoD)
- All code follows the **NexusArchitect** architectural guidelines.
- Unit tests cover core Identity logic (e.g., token limit validation).
- UI is responsive and consistent with the provided Dark Mode design.
- Documentation updated with setup instructions for new developers.
+1 -1
View File
@@ -1,6 +1,6 @@
services:
db:
image: postgres:17-alpine
image: pgvector/pgvector:pg17
container_name: nexus-db
environment:
POSTGRES_USER: nexus_user
@@ -0,0 +1,9 @@
namespace NexusReader.Application.DTOs.User;
public record SubscriptionPlanDto
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public int AITokenLimit { get; init; }
public decimal MonthlyPrice { get; init; }
}
@@ -0,0 +1,25 @@
namespace NexusReader.Application.DTOs.User;
public record UserProfileDto
{
public string Email { get; init; } = string.Empty;
public int AITokensUsed { get; init; }
/// <summary>
/// Relational data for the current subscription plan.
/// </summary>
public SubscriptionPlanDto Plan { get; init; } = new();
public int AverageQuizScore { get; init; }
/// <summary>
/// Summary of the last read book.
/// </summary>
public LastReadBookDto? LastReadBook { get; init; }
}
public record LastReadBookDto
{
public Guid Id { get; init; }
public string Title { get; init; } = string.Empty;
}
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using NexusReader.Application.DTOs.AI;
using NexusReader.Application.Abstractions.Persistence;
using Pgvector;
using Pgvector.EntityFrameworkCore;
using System.Text.Json;
@@ -37,7 +38,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
{
// 1. Generate embedding for user query
var embeddingResponse = await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: cancellationToken);
var queryVector = embeddingResponse.First().Vector.ToArray();
var queryVector = new Vector(embeddingResponse.First().Vector.ToArray());
// 2. Perform Cosine Similarity Search on Knowledge Units
var candidates = await _dbContext.KnowledgeUnits
@@ -31,7 +31,7 @@ public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
}
// Rule 1: Explicit Pro plan
if (user.CurrentPlan == "Pro")
if (user.SubscriptionPlanId == SubscriptionPlan.ProId)
{
context.Succeed(requirement);
return;
+4
View File
@@ -23,6 +23,10 @@ public class Ebook
public string? CoverUrl { get; set; }
[Required]
[MaxLength(128)]
public string TenantId { get; set; } = "global";
public DateTime AddedDate { get; set; } = DateTime.UtcNow;
public DateTime? LastReadDate { get; set; }
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NexusReader.Domain.Enums;
using Pgvector;
namespace NexusReader.Domain.Entities;
@@ -30,8 +31,7 @@ public class KnowledgeUnit
[MaxLength(128)]
public string TenantId { get; set; } = string.Empty;
[Column(TypeName = "vector(768)")] // Default for text-embedding-004
public float[]? Vector { get; set; }
public Vector? Vector { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+25 -9
View File
@@ -1,34 +1,49 @@
using Microsoft.AspNetCore.Identity;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Extended Identity user for the Nexus AI E-Reader SaaS platform.
/// </summary>
public class NexusUser : IdentityUser
{
/// <summary>
/// Total number of AI tokens allowed for the current billing period.
/// User's display name or full name.
/// </summary>
[MaxLength(100)]
public string? DisplayName { get; set; }
/// <summary>
/// Total AI tokens available for the user (depends on subscription).
/// </summary>
public int AITokenLimit { get; set; }
/// <summary>
/// Number of AI tokens consumed in the current billing period.
/// AI tokens consumed by the user in the current billing period.
/// </summary>
public int AITokensUsed { get; set; }
/// <summary>
/// Unique identifier for the tenant (SaaS multi-tenancy support).
/// Date when the user last performed an AI-related action.
/// </summary>
public DateTime? LastAiActionDate { get; set; }
/// <summary>
/// Multi-tenant identifier.
/// </summary>
[Required]
[MaxLength(128)]
public string TenantId { get; set; } = "global";
/// <summary>
/// Current subscription plan (e.g., "Free", "Pro", "Enterprise").
/// Foreign key for the current subscription plan.
/// </summary>
public string CurrentPlan { get; set; } = "Free";
[Required]
public int SubscriptionPlanId { get; set; }
/// <summary>
/// Navigation property for the current subscription plan.
/// </summary>
public SubscriptionPlan? SubscriptionPlan { get; set; }
/// <summary>
/// Collection of e-books owned by the user.
@@ -43,10 +58,11 @@ public class NexusUser : IdentityUser
/// <summary>
/// ID of the last page read by the user.
/// </summary>
[MaxLength(255)]
public string? LastReadPageId { get; set; }
/// <summary>
/// Timestamp of the last reading progress update.
/// Last read timestamp.
/// </summary>
public DateTime? LastReadAt { get; set; }
}
@@ -17,6 +17,10 @@ public class QuizResult
[ForeignKey(nameof(UserId))]
public NexusUser? User { get; set; }
[Required]
[MaxLength(128)]
public string TenantId { get; set; } = "global";
[Required]
public string Topic { get; set; } = string.Empty;
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Pgvector;
namespace NexusReader.Domain.Entities;
@@ -27,8 +28,7 @@ public class SemanticKnowledgeCache
[MaxLength(128)]
public string TenantId { get; set; } = string.Empty;
[Column(TypeName = "vector(1536)")] // text-embedding-004 has 768 or 1536 dims, assuming 1536 for high-fidelity
public float[]? Vector { get; set; }
public Vector? Vector { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
namespace NexusReader.Domain.Entities;
public class SubscriptionPlan
{
public const string FreeName = "Free";
public const string BasicName = "Basic";
public const string ProName = "Pro";
public const string EnterpriseName = "Enterprise";
public const int FreeId = 1;
public const int BasicId = 2;
public const int ProId = 3;
public const int EnterpriseId = 4;
[Key]
public int Id { get; set; }
[Required]
[MaxLength(50)]
public string PlanName { get; set; } = string.Empty;
public int AITokenLimit { get; set; }
public decimal MonthlyPrice { get; set; }
[MaxLength(50)]
public string StripeProductId { get; set; } = string.Empty;
}
@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
<PackageReference Include="Pgvector" Version="0.3.2" />
</ItemGroup>
</Project>
@@ -6,6 +6,7 @@ public class AiSettings
public string ApiKey { get; set; } = string.Empty;
public string Model { get; set; } = "gemini-1.5-flash";
public string EmbeddingModel { get; set; } = "text-embedding-004";
/// <summary>
/// Maximum number of tokens allowed for input.
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Pgvector.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using GeminiDotnet;
@@ -24,13 +25,13 @@ public static class DependencyInjection
var pgConnectionString = configuration.GetConnectionString("PostgresConnection");
if (!string.IsNullOrEmpty(pgConnectionString))
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(pgConnectionString));
services.AddDbContextFactory<AppDbContext>(options =>
options.UseNpgsql(pgConnectionString, x => x.UseVector()));
}
else
{
var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db";
services.AddDbContext<AppDbContext>(options =>
services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite(sqliteConnectionString));
}
@@ -64,6 +65,12 @@ public static class DependencyInjection
ModelId = aiSettings.Model
}));
services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions
{
ApiKey = aiSettings.ApiKey,
ModelId = aiSettings.EmbeddingModel ?? "text-embedding-004"
}));
services.AddScoped<IKnowledgeService, KnowledgeService>();
services.AddTransient<IEpubService, EpubService>();
@@ -0,0 +1,652 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
#nullable disable
namespace NexusReader.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260503175906_FinalNormalizedSubscriptionArchitecture")]
partial class FinalNormalizedSubscriptionArchitecture
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("CoverUrl")
.HasColumnType("text");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("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<string>("Id")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MetadataJson")
.HasColumnType("text");
b.Property<string>("SourceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<Vector>("Vector")
.HasColumnType("vector(768)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("RelationType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("SourceUnitId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("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<string>("Id")
.HasColumnType("text");
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<int>("AITokensUsed")
.HasColumnType("integer");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<DateTime?>("LastAiActionDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("LastReadAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastReadPageId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<int>("SubscriptionPlanId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CompletedDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("Score")
.HasColumnType("integer");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
b.Property<int>("TotalQuestions")
.HasColumnType("integer");
b.Property<string>("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<string>("ContentHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("JsonData")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ModelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalText")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Vector>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("PlanName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("PlanName")
.IsUnique();
b.ToTable("SubscriptionPlans");
b.HasData(
new
{
Id = 1,
AITokenLimit = 1000,
MonthlyPrice = 0m,
PlanName = "Free",
StripeProductId = ""
},
new
{
Id = 2,
AITokenLimit = 10000,
MonthlyPrice = 9.99m,
PlanName = "Basic",
StripeProductId = "prod_basic_placeholder"
},
new
{
Id = 3,
AITokenLimit = 50000,
MonthlyPrice = 19.99m,
PlanName = "Pro",
StripeProductId = "prod_pro_placeholder"
},
new
{
Id = 4,
AITokenLimit = 500000,
MonthlyPrice = 99.99m,
PlanName = "Enterprise",
StripeProductId = "prod_enterprise_placeholder"
});
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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
}
}
}
@@ -0,0 +1,399 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace NexusReader.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class FinalNormalizedSubscriptionArchitecture : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CurrentPlan",
table: "AspNetUsers");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "SemanticKnowledgeCache",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
migrationBuilder.AddColumn<string>(
name: "OriginalText",
table: "SemanticKnowledgeCache",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TenantId",
table: "SemanticKnowledgeCache",
type: "character varying(128)",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<Vector>(
name: "Vector",
table: "SemanticKnowledgeCache",
type: "vector(1536)",
nullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "CompletedDate",
table: "QuizResults",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
migrationBuilder.AddColumn<string>(
name: "TenantId",
table: "QuizResults",
type: "character varying(128)",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.AlterColumn<DateTime>(
name: "LastReadDate",
table: "Ebooks",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "AddedDate",
table: "Ebooks",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
migrationBuilder.AddColumn<string>(
name: "TenantId",
table: "Ebooks",
type: "character varying(128)",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.AlterColumn<string>(
name: "TenantId",
table: "AspNetUsers",
type: "character varying(128)",
maxLength: 128,
nullable: false,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<string>(
name: "DisplayName",
table: "AspNetUsers",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastAiActionDate",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastReadAt",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastReadPageId",
table: "AspNetUsers",
type: "character varying(255)",
maxLength: 255,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "SubscriptionPlanId",
table: "AspNetUsers",
type: "integer",
nullable: false,
defaultValue: 1);
migrationBuilder.CreateTable(
name: "KnowledgeUnits",
columns: table => new
{
Id = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
SourceId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Version = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
Content = table.Column<string>(type: "text", nullable: false),
MetadataJson = table.Column<string>(type: "text", nullable: true),
TenantId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Vector = table.Column<Vector>(type: "vector(768)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KnowledgeUnits", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SubscriptionPlans",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PlanName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
AITokenLimit = table.Column<int>(type: "integer", nullable: false),
MonthlyPrice = table.Column<decimal>(type: "numeric", nullable: false),
StripeProductId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SubscriptionPlans", x => x.Id);
});
migrationBuilder.CreateTable(
name: "KnowledgeUnitLinks",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SourceUnitId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
TargetUnitId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
RelationType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KnowledgeUnitLinks", x => x.Id);
table.ForeignKey(
name: "FK_KnowledgeUnitLinks_KnowledgeUnits_SourceUnitId",
column: x => x.SourceUnitId,
principalTable: "KnowledgeUnits",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_KnowledgeUnitLinks_KnowledgeUnits_TargetUnitId",
column: x => x.TargetUnitId,
principalTable: "KnowledgeUnits",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "SubscriptionPlans",
columns: new[] { "Id", "AITokenLimit", "MonthlyPrice", "PlanName", "StripeProductId" },
values: new object[,]
{
{ 1, 1000, 0m, "Free", "" },
{ 2, 10000, 9.99m, "Basic", "prod_basic_placeholder" },
{ 3, 50000, 19.99m, "Pro", "prod_pro_placeholder" },
{ 4, 500000, 99.99m, "Enterprise", "prod_enterprise_placeholder" }
});
migrationBuilder.CreateIndex(
name: "IX_SemanticKnowledgeCache_TenantId",
table: "SemanticKnowledgeCache",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_QuizResults_TenantId",
table: "QuizResults",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_Ebooks_TenantId",
table: "Ebooks",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUsers_SubscriptionPlanId",
table: "AspNetUsers",
column: "SubscriptionPlanId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUsers_TenantId",
table: "AspNetUsers",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_KnowledgeUnitLinks_SourceUnitId",
table: "KnowledgeUnitLinks",
column: "SourceUnitId");
migrationBuilder.CreateIndex(
name: "IX_KnowledgeUnitLinks_TargetUnitId",
table: "KnowledgeUnitLinks",
column: "TargetUnitId");
migrationBuilder.CreateIndex(
name: "IX_KnowledgeUnits_SourceId",
table: "KnowledgeUnits",
column: "SourceId");
migrationBuilder.CreateIndex(
name: "IX_KnowledgeUnits_TenantId",
table: "KnowledgeUnits",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_SubscriptionPlans_PlanName",
table: "SubscriptionPlans",
column: "PlanName",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_AspNetUsers_SubscriptionPlans_SubscriptionPlanId",
table: "AspNetUsers",
column: "SubscriptionPlanId",
principalTable: "SubscriptionPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AspNetUsers_SubscriptionPlans_SubscriptionPlanId",
table: "AspNetUsers");
migrationBuilder.DropTable(
name: "KnowledgeUnitLinks");
migrationBuilder.DropTable(
name: "SubscriptionPlans");
migrationBuilder.DropTable(
name: "KnowledgeUnits");
migrationBuilder.DropIndex(
name: "IX_SemanticKnowledgeCache_TenantId",
table: "SemanticKnowledgeCache");
migrationBuilder.DropIndex(
name: "IX_QuizResults_TenantId",
table: "QuizResults");
migrationBuilder.DropIndex(
name: "IX_Ebooks_TenantId",
table: "Ebooks");
migrationBuilder.DropIndex(
name: "IX_AspNetUsers_SubscriptionPlanId",
table: "AspNetUsers");
migrationBuilder.DropIndex(
name: "IX_AspNetUsers_TenantId",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "OriginalText",
table: "SemanticKnowledgeCache");
migrationBuilder.DropColumn(
name: "TenantId",
table: "SemanticKnowledgeCache");
migrationBuilder.DropColumn(
name: "Vector",
table: "SemanticKnowledgeCache");
migrationBuilder.DropColumn(
name: "TenantId",
table: "QuizResults");
migrationBuilder.DropColumn(
name: "TenantId",
table: "Ebooks");
migrationBuilder.DropColumn(
name: "DisplayName",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "LastAiActionDate",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "LastReadAt",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "LastReadPageId",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "SubscriptionPlanId",
table: "AspNetUsers");
migrationBuilder.AlterDatabase()
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "SemanticKnowledgeCache",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AlterColumn<DateTime>(
name: "CompletedDate",
table: "QuizResults",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AlterColumn<DateTime>(
name: "LastReadDate",
table: "Ebooks",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "AddedDate",
table: "Ebooks",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AlterColumn<Guid>(
name: "TenantId",
table: "AspNetUsers",
type: "uuid",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(128)",
oldMaxLength: 128);
migrationBuilder.AddColumn<string>(
name: "CurrentPlan",
table: "AspNetUsers",
type: "text",
nullable: false,
defaultValue: "");
}
}
}
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
#nullable disable
@@ -20,6 +21,7 @@ namespace NexusReader.Infrastructure.Migrations
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
@@ -161,7 +163,7 @@ namespace NexusReader.Infrastructure.Migrations
.HasColumnType("uuid");
b.Property<DateTime>("AddedDate")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<string>("Author")
.IsRequired()
@@ -176,7 +178,12 @@ namespace NexusReader.Infrastructure.Migrations
.HasColumnType("text");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
@@ -189,11 +196,91 @@ namespace NexusReader.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Property<string>("Id")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MetadataJson")
.HasColumnType("text");
b.Property<string>("SourceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<Vector>("Vector")
.HasColumnType("vector(768)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("RelationType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("SourceUnitId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("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<string>("Id")
@@ -212,9 +299,9 @@ namespace NexusReader.Infrastructure.Migrations
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("CurrentPlan")
.IsRequired()
.HasColumnType("text");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(256)
@@ -223,6 +310,16 @@ namespace NexusReader.Infrastructure.Migrations
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<DateTime?>("LastAiActionDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("LastReadAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastReadPageId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
@@ -249,8 +346,15 @@ namespace NexusReader.Infrastructure.Migrations
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<int>("SubscriptionPlanId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
@@ -268,6 +372,10 @@ namespace NexusReader.Infrastructure.Migrations
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.HasIndex("SubscriptionPlanId");
b.HasIndex("TenantId");
b.ToTable("AspNetUsers", (string)null);
});
@@ -278,11 +386,16 @@ namespace NexusReader.Infrastructure.Migrations
.HasColumnType("uuid");
b.Property<DateTime>("CompletedDate")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<int>("Score")
.HasColumnType("integer");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
@@ -296,6 +409,8 @@ namespace NexusReader.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("QuizResults");
@@ -308,7 +423,7 @@ namespace NexusReader.Infrastructure.Migrations
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<string>("JsonData")
.IsRequired()
@@ -319,19 +434,99 @@ namespace NexusReader.Infrastructure.Migrations
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalText")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Vector>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("PlanName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("PlanName")
.IsUnique();
b.ToTable("SubscriptionPlans");
b.HasData(
new
{
Id = 1,
AITokenLimit = 1000,
MonthlyPrice = 0m,
PlanName = "Free",
StripeProductId = ""
},
new
{
Id = 2,
AITokenLimit = 10000,
MonthlyPrice = 9.99m,
PlanName = "Basic",
StripeProductId = "prod_basic_placeholder"
},
new
{
Id = 3,
AITokenLimit = 50000,
MonthlyPrice = 19.99m,
PlanName = "Pro",
StripeProductId = "prod_pro_placeholder"
},
new
{
Id = 4,
AITokenLimit = 500000,
MonthlyPrice = 99.99m,
PlanName = "Enterprise",
StripeProductId = "prod_enterprise_placeholder"
});
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -394,6 +589,36 @@ namespace NexusReader.Infrastructure.Migrations
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
{
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
.WithMany("OutgoingLinks")
.HasForeignKey("SourceUnitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
.WithMany("IncomingLinks")
.HasForeignKey("TargetUnitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SourceUnit");
b.Navigation("TargetUnit");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
.WithMany()
.HasForeignKey("SubscriptionPlanId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SubscriptionPlan");
});
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
@@ -405,6 +630,13 @@ namespace NexusReader.Infrastructure.Migrations
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Navigation("IncomingLinks");
b.Navigation("OutgoingLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Navigation("Ebooks");
@@ -22,6 +22,7 @@
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
<PackageReference Include="Microsoft.ML.Tokenizers.Data.Cl100kBase" Version="2.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
<PackageReference Include="Polly" Version="8.6.6" />
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageReference Include="Stripe.net" Version="51.1.0" />
@@ -16,25 +16,41 @@ public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
public DbSet<KnowledgeUnitLink> KnowledgeUnitLinks => Set<KnowledgeUnitLink>();
public DbSet<Ebook> Ebooks => Set<Ebook>();
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("pgvector");
modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<NexusUser>(entity =>
{
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
entity.Property(u => u.LastReadAt).IsRequired(false);
entity.HasIndex(u => u.TenantId);
entity.HasOne(u => u.SubscriptionPlan)
.WithMany()
.HasForeignKey(u => u.SubscriptionPlanId)
.OnDelete(DeleteBehavior.Restrict);
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
entity.Property(u => u.SubscriptionPlanId)
.HasDefaultValue(1);
});
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<SubscriptionPlan>(entity =>
{
entity.HasIndex(p => p.PlanName).IsUnique();
});
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
{
entity.HasKey(e => e.ContentHash);
entity.HasIndex(e => e.ContentHash).IsUnique();
entity.HasIndex(e => e.TenantId);
entity.Property(e => e.Vector).HasColumnType("vector(1536)"); // Standard for many models
entity.Property(e => e.Vector).HasColumnType("vector(1536)");
});
modelBuilder.Entity<KnowledgeUnit>(entity =>
@@ -42,7 +58,7 @@ public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.TenantId);
entity.HasIndex(e => e.SourceId);
entity.Property(e => e.Vector).HasColumnType("vector(768)"); // text-embedding-004
entity.Property(e => e.Vector).HasColumnType("vector(768)");
});
modelBuilder.Entity<KnowledgeUnitLink>(entity =>
@@ -65,6 +81,8 @@ public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
.WithMany(u => u.Ebooks)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(e => e.TenantId);
});
modelBuilder.Entity<QuizResult>(entity =>
@@ -73,6 +91,16 @@ public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
.WithMany(u => u.QuizResults)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(e => e.TenantId);
});
// Seed Subscription Plans with deterministic IDs
modelBuilder.Entity<SubscriptionPlan>().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" }
);
}
}
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using Pgvector.EntityFrameworkCore;
namespace NexusReader.Infrastructure.Persistence;
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development";
// Try to find the Web project directory by looking for the solution root
var currentDir = new DirectoryInfo(Directory.GetCurrentDirectory());
while (currentDir != null && !File.Exists(Path.Combine(currentDir.FullName, "NexusReader.slnx")))
{
currentDir = currentDir.Parent;
}
var basePath = currentDir != null
? Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New")
: Directory.GetCurrentDirectory();
var configuration = new ConfigurationBuilder()
.SetBasePath(basePath)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile($"appsettings.{environment}.json", optional: true)
.AddEnvironmentVariables()
.Build();
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
var connectionString = configuration.GetConnectionString("PostgresConnection");
if (string.IsNullOrEmpty(connectionString))
{
// For design time, if no PG connection is found, we might be using Sqlite or just testing
connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres";
}
optionsBuilder.UseNpgsql(connectionString, x => x.UseVector());
return new AppDbContext(optionsBuilder.Options);
}
}
@@ -4,6 +4,8 @@ using NexusReader.Domain.Entities;
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
namespace NexusReader.Infrastructure.Persistence;
@@ -14,11 +16,25 @@ public static class DbInitializer
using var scope = serviceProvider.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<NexusUser>>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
try
{
Console.WriteLine("[Seeder] Starting database seeding...");
// Seed Subscription Plans
if (!dbContext.SubscriptionPlans.Any())
{
dbContext.SubscriptionPlans.AddRange(new List<SubscriptionPlan>
{
new SubscriptionPlan { Id = SubscriptionPlan.FreeId, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, MonthlyPrice = 0, StripeProductId = "prod_Free789" },
new SubscriptionPlan { Id = SubscriptionPlan.ProId, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, MonthlyPrice = 19, StripeProductId = "prod_Pro123" },
new SubscriptionPlan { Id = SubscriptionPlan.EnterpriseId, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 500000, MonthlyPrice = 99, StripeProductId = "prod_Enterprise456" }
});
await dbContext.SaveChangesAsync();
Console.WriteLine("[Seeder] Subscription plans seeded.");
}
// Seed Roles
string[] roleNames = { "Admin", "User" };
foreach (var roleName in roleNames)
@@ -42,7 +58,7 @@ public static class DbInitializer
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true,
CurrentPlan = "Enterprise",
SubscriptionPlanId = SubscriptionPlan.EnterpriseId,
AITokenLimit = 1000000,
TenantId = Guid.NewGuid().ToString()
};
@@ -37,26 +37,29 @@ public class BillingService : IBillingService
return false;
}
string targetPlanName = SubscriptionPlan.FreeName;
int tokenLimit = 1000;
if (stripeProductId == _stripeSettings.ProProductId)
{
user.CurrentPlan = "Pro";
user.AITokenLimit = 50000;
targetPlanName = SubscriptionPlan.ProName;
tokenLimit = 50000;
}
else if (stripeProductId == _stripeSettings.BasicProductId)
{
user.CurrentPlan = "Basic";
user.AITokenLimit = 10000;
targetPlanName = SubscriptionPlan.BasicName;
tokenLimit = 10000;
}
else if (stripeProductId == _stripeSettings.FreeProductId || string.IsNullOrEmpty(stripeProductId))
else if (!string.IsNullOrEmpty(stripeProductId) && stripeProductId != _stripeSettings.FreeProductId)
{
user.CurrentPlan = "Free";
user.AITokenLimit = 1000;
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
}
else
var plan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
if (plan != null)
{
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
user.CurrentPlan = "Free";
user.AITokenLimit = 1000;
user.SubscriptionPlanId = plan.Id;
user.AITokenLimit = tokenLimit;
}
var result = await _userManager.UpdateAsync(user);
@@ -79,8 +82,12 @@ public class BillingService : IBillingService
return false;
}
user.CurrentPlan = "Free";
user.AITokenLimit = 1000; // Reset to free limit
var freePlan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
if (freePlan != null)
{
user.SubscriptionPlanId = freePlan.Id;
user.AITokenLimit = freePlan.AITokenLimit;
}
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
@@ -41,7 +41,20 @@ public class EpubService : IEpubService
return Result.Fail($"EPUB file not found. Checked {searchPaths.Count} locations, including: {string.Join(", ", searchPaths.Take(3))}");
}
EpubBook book = await EpubReader.ReadBookAsync(fullPath);
if (!File.Exists(fullPath))
{
return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist.");
}
EpubBook book;
try
{
book = await EpubReader.ReadBookAsync(fullPath);
}
catch (Exception ex)
{
return Result.Fail(new Error($"Failed to parse EPUB file. It might be corrupted or in use. Path: {fullPath}").CausedBy(ex));
}
var blocks = new List<ContentBlock>();
int totalWordCount = 0;
int blockCounter = 0;
@@ -12,6 +12,7 @@ using Polly;
using Polly.Registry;
using Microsoft.Extensions.Options;
using NexusReader.Infrastructure.Configuration;
using Pgvector;
using Pgvector.EntityFrameworkCore;
namespace NexusReader.Infrastructure.Services;
@@ -20,7 +21,7 @@ public class KnowledgeService : IKnowledgeService
{
private readonly IChatClient _chatClient;
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
private readonly AppDbContext _dbContext;
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly ResiliencePipeline _retryPipeline;
private readonly AiSettings _settings;
private readonly Tokenizer _tokenizer;
@@ -29,13 +30,13 @@ public class KnowledgeService : IKnowledgeService
public KnowledgeService(
IChatClient chatClient,
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
AppDbContext dbContext,
IDbContextFactory<AppDbContext> dbContextFactory,
ResiliencePipelineProvider<string> pipelineProvider,
IOptions<AiSettings> settings)
{
_chatClient = chatClient;
_embeddingGenerator = embeddingGenerator;
_dbContext = dbContext;
_dbContextFactory = dbContextFactory;
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
_settings = settings.Value;
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
@@ -63,40 +64,30 @@ public class KnowledgeService : IKnowledgeService
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken);
}
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string cacheSuffix, CancellationToken cancellationToken)
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string traceType, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(text))
{
return Result.Fail("Input text is empty.");
}
if (string.IsNullOrWhiteSpace(text)) return Result.Fail("Input text is empty.");
Console.WriteLine($"[KnowledgeService] Starting extraction ({cacheSuffix}) for text sample: {text.Substring(0, Math.Min(text.Length, 50))}...");
var normalizedText = ContentHasher.Normalize(text);
var tokenCount = EstimateTokenCount(normalizedText);
if (tokenCount > _settings.MaxInputTokens)
{
return Result.Fail($"Input exceeds maximum token limit. Estimated tokens: {tokenCount}, limit: {_settings.MaxInputTokens}.");
}
var hash = ContentHasher.ComputeHash(normalizedText) + "_" + cacheSuffix;
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var normalizedText = text.Trim();
var hash = ContentHasher.ComputeHash(normalizedText);
// 1. Check Cache
var cached = await _dbContext.SemanticKnowledgeCache
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId && c.PromptVersion == PromptVersion, cancellationToken);
var cached = await dbContext.SemanticKnowledgeCache
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId, cancellationToken);
if (cached != null)
if (cached != null && cached.PromptVersion == PromptVersion)
{
Console.WriteLine($"[KnowledgeService] Cache Hit for {traceType} ({hash})");
try
{
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (packet != null) return Result.Ok(packet);
}
catch { }
catch { /* fallback to regen */ }
}
// 2. Call AI Client
Console.WriteLine($"[KnowledgeService] Cache Miss for {traceType} ({hash}). Requesting AI...");
try
{
var options = new ChatOptions
@@ -147,26 +138,23 @@ public class KnowledgeService : IKnowledgeService
ModelId = _settings.Model,
PromptVersion = PromptVersion,
TenantId = tenantId,
Vector = vector,
Vector = vector != null ? new Vector(vector) : null,
CreatedAt = DateTime.UtcNow
};
if (cached == null) _dbContext.SemanticKnowledgeCache.Add(cacheEntry);
if (cached == null) dbContext.SemanticKnowledgeCache.Add(cacheEntry);
else
{
cached.JsonData = jsonResponse;
cached.OriginalText = normalizedText;
cached.Vector = vector;
cached.Vector = vector != null ? new Vector(vector) : null;
cached.CreatedAt = DateTime.UtcNow;
}
// 5. Process KM-RAG Units and Links if present
if (knowledgePacket.Units.Any())
{
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, cancellationToken);
}
// 5. Process structured KnowledgeUnits (Graph Expansion)
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, dbContext, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Ok(knowledgePacket);
}
catch (JsonException ex)
@@ -181,39 +169,70 @@ public class KnowledgeService : IKnowledgeService
}
}
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, CancellationToken cancellationToken)
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, AppDbContext dbContext, CancellationToken cancellationToken)
{
var unitIds = packet.Units.Select(u => u.Id).ToList();
var linkSourceIds = packet.Links.Select(l => l.Source).ToList();
var linkTargetIds = packet.Links.Select(l => l.Target).ToList();
var allCandidateIds = unitIds.Concat(linkSourceIds).Concat(linkTargetIds).Distinct().ToList();
// Single batch query to find existing units
var existingUnits = await dbContext.KnowledgeUnits
.Where(u => allCandidateIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, cancellationToken);
var processedUnitIds = new HashSet<string>();
foreach (var unitDto in packet.Units)
{
var unitId = unitDto.Id;
var existing = await _dbContext.KnowledgeUnits.FindAsync(new object[] { unitId }, cancellationToken);
existingUnits.TryGetValue(unitId, out var unit);
if (unit == null)
{
unit = new KnowledgeUnit { Id = unitId, TenantId = tenantId };
dbContext.KnowledgeUnits.Add(unit);
existingUnits[unitId] = unit;
}
var unit = existing ?? new KnowledgeUnit { Id = unitId, TenantId = tenantId };
unit.Type = Enum.TryParse<NexusReader.Domain.Enums.KnowledgeUnitType>(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet;
unit.Content = unitDto.Content;
unit.SourceId = "extracted";
unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata);
// Generate unit-specific embedding for granular retrieval
try
{
var emb = await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, cancellationToken: cancellationToken);
unit.Vector = emb.First().Vector.ToArray();
var emb = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, cancellationToken: ct), cancellationToken);
unit.Vector = new Vector(emb.First().Vector.ToArray());
}
catch { /* Ignore embedding errors for now */ }
if (existing == null) _dbContext.KnowledgeUnits.Add(unit);
processedUnitIds.Add(unit.Id);
}
foreach (var linkDto in packet.Links)
{
var link = new KnowledgeUnitLink
var sourceExists = processedUnitIds.Contains(linkDto.Source) || existingUnits.ContainsKey(linkDto.Source);
var targetExists = processedUnitIds.Contains(linkDto.Target) || existingUnits.ContainsKey(linkDto.Target);
if (sourceExists && targetExists)
{
SourceUnitId = linkDto.Source,
TargetUnitId = linkDto.Target,
RelationType = linkDto.Relation
};
_dbContext.KnowledgeUnitLinks.Add(link);
// Check if link already exists to avoid duplicates if necessary
// For now, assume we can add them or they are new in this session
var link = new KnowledgeUnitLink
{
SourceUnitId = linkDto.Source,
TargetUnitId = linkDto.Target,
RelationType = linkDto.Relation
};
dbContext.KnowledgeUnitLinks.Add(link);
}
else
{
Console.WriteLine($"[KnowledgeService] WARNING: Skipping invalid link {linkDto.Source} -> {linkDto.Target} (Missing units).");
}
}
}
@@ -257,30 +276,21 @@ public class KnowledgeService : IKnowledgeService
public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query)) return Result.Fail("Query is empty.");
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
try
{
// 1. Generate embedding for query
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
var queryEmbedding = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(new[] { query }, cancellationToken: ct), cancellationToken);
var queryVector = embeddingResponse.First().Vector.ToArray();
var queryVector = new Vector(queryEmbedding.First().Vector.ToArray());
// 2. Search using pgvector
var results = await _dbContext.SemanticKnowledgeCache
.AsNoTracking()
.Where(x => (x.TenantId == tenantId || x.TenantId == "global") && x.Vector != null)
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
var relevantUnits = await dbContext.KnowledgeUnits
.Where(u => u.TenantId == tenantId)
.OrderBy(u => u.Vector!.L2Distance(queryVector))
.Take(5)
.Select(x => new RelevantContext
{
Text = x.OriginalText,
SourceId = x.ContentHash,
Confidence = 1 - x.Vector!.CosineDistance(queryVector)
})
.Select(u => new RelevantContext { Text = u.Content, Confidence = 1.0 })
.ToListAsync(cancellationToken);
return Result.Ok(results);
return Result.Ok(relevantUnits);
}
catch (Exception ex)
{
@@ -290,16 +300,17 @@ public class KnowledgeService : IKnowledgeService
public async Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
try
{
Console.WriteLine("[KnowledgeService] Clearing SemanticKnowledgeCache...");
_dbContext.SemanticKnowledgeCache.RemoveRange(_dbContext.SemanticKnowledgeCache);
await _dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken);
await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken);
await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail($"Failed to clear cache: {ex.Message}");
return Result.Fail(new Error("Failed to clear knowledge cache").CausedBy(ex));
}
}
@@ -54,6 +54,7 @@
protected override void OnInitialized()
{
Coordinator.Clear();
ThemeService.OnThemeChanged += StateHasChanged;
NavigationService.OnNavigationChanged += OnNavigationChanged;
@@ -3,16 +3,18 @@ using NexusReader.Application.Queries.Graph;
using NexusReader.Application.Queries.Quiz;
using NexusReader.UI.Shared.Services;
using NexusReader.Application.DTOs.AI;
using Microsoft.Extensions.Logging;
namespace NexusReader.UI.Shared.Services;
public sealed class KnowledgeCoordinator : IDisposable
public sealed partial class KnowledgeCoordinator : IDisposable
{
private readonly IKnowledgeService _knowledgeService;
private readonly IKnowledgeGraphService _graphService;
private readonly IQuizStateService _quizService;
private readonly IPlatformService _platformService;
private readonly IReaderInteractionService _interactionService;
private readonly ILogger<KnowledgeCoordinator> _logger;
public event Action<GraphDataDto>? OnGraphUpdated;
@@ -21,13 +23,15 @@ public sealed class KnowledgeCoordinator : IDisposable
IKnowledgeGraphService graphService,
IQuizStateService quizService,
IPlatformService platformService,
IReaderInteractionService interactionService)
IReaderInteractionService interactionService,
ILogger<KnowledgeCoordinator> logger)
{
_knowledgeService = knowledgeService;
_graphService = graphService;
_quizService = quizService;
_platformService = platformService;
_interactionService = interactionService;
_logger = logger;
_interactionService.OnNodeSelected += HandleNodeSelected;
}
@@ -42,7 +46,7 @@ public sealed class KnowledgeCoordinator : IDisposable
{
if (string.IsNullOrWhiteSpace(fullContent)) return;
Console.WriteLine("[KnowledgeCoordinator] Generating full page graph...");
LogGeneratingGraph(tenantId);
_graphService.Clear();
_graphService.SetLoading(true);
@@ -63,7 +67,7 @@ public sealed class KnowledgeCoordinator : IDisposable
}
catch (Exception ex)
{
Console.WriteLine($"[KnowledgeCoordinator] Error generating graph: {ex.Message}");
LogGraphError(ex, tenantId);
}
}
@@ -76,6 +80,7 @@ public sealed class KnowledgeCoordinator : IDisposable
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
{
_quizService.SetHydrating(true);
LogRequestingSummary(tenantId);
try
{
var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId);
@@ -90,6 +95,12 @@ public sealed class KnowledgeCoordinator : IDisposable
await _platformService.VibrateSuccessAsync();
return packet;
}
LogSummaryWarning(tenantId);
}
catch (Exception ex)
{
LogSummaryError(ex, tenantId);
}
finally
{
@@ -98,8 +109,29 @@ public sealed class KnowledgeCoordinator : IDisposable
return null;
}
public void Clear()
{
_graphService.Clear();
_quizService.SetQuiz(null, null);
}
public void Dispose()
{
_interactionService.OnNodeSelected -= HandleNodeSelected;
}
[LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Generating full page graph for tenant: {TenantId}")]
private partial void LogGeneratingGraph(string tenantId);
[LoggerMessage(Level = LogLevel.Error, Message = "[KnowledgeCoordinator] Error generating graph for tenant: {TenantId}")]
private partial void LogGraphError(Exception ex, string tenantId);
[LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Requesting summary and quiz for tenant: {TenantId}")]
private partial void LogRequestingSummary(string tenantId);
[LoggerMessage(Level = LogLevel.Warning, Message = "[KnowledgeCoordinator] Failed to get summary and quiz for tenant: {TenantId}")]
private partial void LogSummaryWarning(string tenantId);
[LoggerMessage(Level = LogLevel.Error, Message = "[KnowledgeCoordinator] Error requesting summary and quiz for tenant: {TenantId}")]
private partial void LogSummaryError(Exception ex, string tenantId);
}
+48 -29
View File
@@ -2,6 +2,7 @@ using NexusReader.Web.Components;
using NexusReader.Application;
using NexusReader.Infrastructure;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.User;
using NexusReader.Web.Client.Services;
using NexusReader.UI.Shared.Services;
using NexusReader.Domain.Entities;
@@ -67,7 +68,7 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
// Authorization Policies
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise"))
.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", SubscriptionPlan.ProName, SubscriptionPlan.EnterpriseName))
.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
// Billing & Stripe
@@ -245,8 +246,6 @@ knowledgeApi.MapPost("/verify-groundedness", async (GroundednessRequest request,
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
});
knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
{
var result = await knowledgeService.ClearCacheAsync();
@@ -256,8 +255,13 @@ knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
return Results.BadRequest(errorMsg);
});
app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager<NexusUser> userManager, IConfiguration configuration) =>
app.MapPost("/api/StripeWebhook", async (
HttpContext context,
UserManager<NexusUser> userManager,
IConfiguration configuration,
IDbContextFactory<AppDbContext> dbContextFactory) =>
{
using var dbContext = await dbContextFactory.CreateDbContextAsync();
var json = await new StreamReader(context.Request.Body).ReadToEndAsync();
var webhookSecret = configuration["Stripe:WebhookSecret"] ?? "";
@@ -273,20 +277,19 @@ app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager<NexusU
{
case EventTypes.CheckoutSessionCompleted:
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata, userManager);
await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata, userManager, dbContext);
break;
case EventTypes.CustomerSubscriptionUpdated:
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager);
await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager, dbContext);
break;
case EventTypes.CustomerSubscriptionDeleted:
var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription;
await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager);
await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager, dbContext);
break;
}
return Results.Ok();
}
catch (StripeException e)
@@ -295,36 +298,43 @@ app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager<NexusU
}
});
async Task HandleSubscriptionSuccess(string? email, Dictionary<string, string>? metadata, UserManager<NexusUser> userManager)
async Task HandleSubscriptionSuccess(
string? email,
Dictionary<string, string>? metadata,
UserManager<NexusUser> userManager,
AppDbContext dbContext)
{
if (string.IsNullOrEmpty(email)) return;
var user = await userManager.FindByEmailAsync(email);
if (user != null)
{
var plan = metadata?.GetValueOrDefault("Plan") ?? "Pro";
var planName = metadata?.GetValueOrDefault("Plan") ?? SubscriptionPlan.ProName;
var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == planName);
user.CurrentPlan = plan;
user.AITokenLimit = plan.ToLower() switch
if (plan != null)
{
"pro" => 50000,
"enterprise" => 500000,
_ => 10000 // default for unknown or free
};
user.SubscriptionPlanId = plan.Id;
user.AITokenLimit = plan.AITokenLimit;
}
await userManager.UpdateAsync(user);
}
}
async Task HandleSubscriptionCancellation(string? email, UserManager<NexusUser> userManager)
async Task HandleSubscriptionCancellation(
string? email,
UserManager<NexusUser> userManager,
AppDbContext dbContext)
{
if (string.IsNullOrEmpty(email)) return;
var user = await userManager.FindByEmailAsync(email);
if (user != null)
{
user.CurrentPlan = "Free";
user.AITokenLimit = 5000; // Free tier limit
var freePlan = await dbContext.SubscriptionPlans.FindAsync(SubscriptionPlan.FreeId);
user.SubscriptionPlanId = SubscriptionPlan.FreeId;
user.AITokenLimit = freePlan?.AITokenLimit ?? 5000;
await userManager.UpdateAsync(user);
}
}
@@ -359,7 +369,6 @@ app.MapGet("/identity/callback/google", async (
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
if (email != null)
{
// TODO: REV-5 - Consider redirecting to Terms of Service / Onboarding before final provisioning
var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true };
var createResult = await userManager.CreateAsync(user);
if (createResult.Succeeded)
@@ -373,22 +382,32 @@ app.MapGet("/identity/callback/google", async (
return Results.Redirect("/account/login?error=ProvisioningFailed");
});
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager, AppDbContext dbContext) =>
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager, IDbContextFactory<AppDbContext> dbContextFactory) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Results.Unauthorized();
using var dbContext = await dbContextFactory.CreateDbContextAsync();
var profile = await dbContext.Users
.Where(u => u.Id == userId)
.Select(u => new
.Select(u => new UserProfileDto
{
u.Email,
u.AITokenLimit,
u.AITokensUsed,
u.CurrentPlan,
u.TenantId,
AverageQuizScore = u.QuizResults.Any() ? (int?)u.QuizResults.Average(q => q.Percentage) ?? 0 : 0,
LastReadBookTitle = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => e.Title).FirstOrDefault() ?? "None"
Email = u.Email ?? string.Empty,
AITokensUsed = u.AITokensUsed,
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
{
Id = u.SubscriptionPlan.Id,
Name = u.SubscriptionPlan.PlanName,
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
} : new SubscriptionPlanDto(),
AverageQuizScore = u.QuizResults.Any() ? (int)u.QuizResults.Average(q => q.Percentage) : 0,
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
{
Id = e.Id,
Title = e.Title
}).FirstOrDefault()
})
.FirstOrDefaultAsync();