Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a9fe5a124 |
+87
@@ -0,0 +1,87 @@
|
||||
# 📑 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.
|
||||
@@ -46,4 +46,4 @@ version: 1.0
|
||||
|
||||
> [!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.
|
||||
> All tasks originating from the repository must be performed on a separate branch. To connect to the Git repository, use the `gitea-ovh` MCP server.
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
<Project Path="src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj" />
|
||||
<Project Path="src/NexusReader.Web.Client/NexusReader.Web.Client.csproj" />
|
||||
<Project Path="src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj" />
|
||||
<Project Path="src/NexusReader.Data/NexusReader.Data.csproj" />
|
||||
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/NexusReader.Web.New/">
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# 🤖 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."
|
||||
@@ -0,0 +1,127 @@
|
||||
# 🔍 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
|
||||
|
||||
- **Status:** ✅ Resolved (2026-05-03)
|
||||
- **Implementation:** Added `File.Exists` check and granular `try-catch` around `EpubReader.ReadBookAsync` to prevent unhandled exceptions and provide descriptive error messages.
|
||||
- **DoD:** Corrupted or missing files return `Result.Fail` instead of crashing.
|
||||
|
||||
---
|
||||
|
||||
- **Status:** ✅ Resolved (2026-05-03)
|
||||
- **Implementation:** Verified `IDbContextFactory<AppDbContext>` is correctly registered via `AddDbContextFactory` in `Infrastructure/DependencyInjection.cs`.
|
||||
- **DoD:** Webhook and profile endpoints successfully resolve the factory.
|
||||
|
||||
---
|
||||
|
||||
- **Status:** ✅ Resolved (2026-05-03)
|
||||
- **Implementation:** Implemented `Coordinator.Clear()` (which calls `KnowledgeGraphService.Clear()`) in `ReaderCanvas.razor`'s `OnInitialized`.
|
||||
- **DoD:** Stale graph data is cleared upon component initialization.
|
||||
|
||||
---
|
||||
|
||||
- **Status:** ✅ Resolved (2026-05-03)
|
||||
- **Implementation:** Created `UserProfileDto` to exclude sensitive internal IDs like `TenantId` and DB GUIDs. Updated `/identity/profile` endpoint to project into this DTO using `.Select()`.
|
||||
- **DoD:** Internal IDs are no longer exposed in the profile API.
|
||||
|
||||
---
|
||||
|
||||
- **Status:** ✅ Resolved (2026-05-03)
|
||||
- **Implementation:** Added `HasIndex(x => x.TenantId)` to `NexusUser`, `Ebook`, and `QuizResult` in `AppDbContext`. `KnowledgeUnit` and `SemanticKnowledgeCache` already had them.
|
||||
- **DoD:** Tenant-scoped queries are optimized via DB indexes.
|
||||
|
||||
---
|
||||
|
||||
- **Status:** ✅ Resolved (2026-05-03)
|
||||
- **Implementation:** Refactored `KnowledgeService.ProcessKnowledgeUnitsAsync` to pre-fetch all existing unit IDs in a single batch query, eliminating the N+1 `FindAsync` and `AnyAsync` calls.
|
||||
- **DoD:** Batch processing performance is significantly improved.
|
||||
|
||||
---
|
||||
|
||||
- **Status:** ✅ Resolved (2026-05-03)
|
||||
- **Implementation:** Added `TenantId` property to `Ebook` entity with mandatory validation and index. Updated `AppDbContext` configuration.
|
||||
- **DoD:** Ebooks are now isolated at the database level.
|
||||
|
||||
---
|
||||
|
||||
- **Status:** ✅ Resolved (2026-05-03)
|
||||
- **Implementation:** Added `TenantId` property to `QuizResult` entity with mandatory validation and index.
|
||||
- **DoD:** Quiz results are now isolated at the database level.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 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-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 | 8 resolved |
|
||||
| 🟡 Minor | 8 | Unresolved |
|
||||
| 🧪 Tests | 1 | Unresolved |
|
||||
| **Total** | **21** | **12 resolved** |
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NexusReader.Domain.Entities;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("src/NexusReader.Web.New/appsettings.json")
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
var pgConnectionString = configuration.GetConnectionString("PostgresConnection");
|
||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||
{
|
||||
services.AddDbContext<AppDbContext>(options => options.UseNpgsql(pgConnectionString));
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddDbContext<AppDbContext>(options => options.UseSqlite(configuration.GetConnectionString("SqliteConnection")));
|
||||
}
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
try
|
||||
{
|
||||
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Email == "admin@nexus.com");
|
||||
if (user == null)
|
||||
{
|
||||
Console.WriteLine("User admin@nexus.com NOT FOUND in database.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"User found: {user.Email}, Id: {user.Id}, EmailConfirmed: {user.EmailConfirmed}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error accessing database: {ex.Message}");
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
public class Program
|
||||
{
|
||||
public static void Main()
|
||||
{
|
||||
string input1 = "Hello \n World";
|
||||
string input2 = "Hello World";
|
||||
|
||||
string norm1 = Normalize(input1);
|
||||
string norm2 = Normalize(input2);
|
||||
|
||||
Console.WriteLine($"Input 1: '{input1}' -> Normalized: '{norm1}'");
|
||||
Console.WriteLine($"Input 2: '{input2}' -> Normalized: '{norm2}'");
|
||||
Console.WriteLine($"Match: {norm1 == norm2}");
|
||||
}
|
||||
|
||||
public static string Normalize(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
|
||||
var normalized = Regex.Replace(input.Trim(), @"\s+", " ");
|
||||
return normalized.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
public interface IApplicationDbContext
|
||||
{
|
||||
DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache { get; }
|
||||
DbSet<KnowledgeUnit> KnowledgeUnits { get; }
|
||||
DbSet<KnowledgeUnitLink> KnowledgeUnitLinks { get; }
|
||||
DbSet<Ebook> Ebooks { get; }
|
||||
DbSet<QuizResult> QuizResults { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -5,6 +5,5 @@ public record SubscriptionPlanDto
|
||||
public int Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public int AITokenLimit { get; init; }
|
||||
public bool IsUnlimitedTokens { get; init; }
|
||||
public decimal MonthlyPrice { get; init; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.Data\NexusReader.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -14,7 +13,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -4,8 +4,7 @@ using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using Pgvector;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
@@ -17,14 +16,14 @@ public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId,
|
||||
|
||||
public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly IApplicationDbContext _dbContext;
|
||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||
|
||||
public SearchLibrarySemanticallyQueryHandler(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
IApplicationDbContext dbContext,
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbContext = dbContext;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
}
|
||||
|
||||
@@ -35,7 +34,6 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
||||
return Result.Fail("Query text cannot be empty.");
|
||||
}
|
||||
|
||||
using var dbContext = _dbContextFactory.CreateDbContext();
|
||||
try
|
||||
{
|
||||
// 1. Generate embedding for user query
|
||||
@@ -43,7 +41,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
||||
var queryVector = new Vector(embeddingResponse.First().Vector.ToArray());
|
||||
|
||||
// 2. Perform Cosine Similarity Search on Knowledge Units
|
||||
var candidates = await dbContext.KnowledgeUnits
|
||||
var candidates = await _dbContext.KnowledgeUnits
|
||||
.AsNoTracking()
|
||||
.Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null)
|
||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||
@@ -53,7 +51,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
||||
if (!candidates.Any())
|
||||
{
|
||||
// Fallback to legacy cache if no granular units found
|
||||
var legacyResults = await dbContext.SemanticKnowledgeCache
|
||||
var legacyResults = await _dbContext.SemanticKnowledgeCache
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == request.TenantId && x.Vector != null)
|
||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||
@@ -70,13 +68,13 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
||||
|
||||
// 3. Graph Expansion: Pull related units (e.g. Definitions, Next steps)
|
||||
var candidateIds = candidates.Select(c => c.Id).ToList();
|
||||
var links = await dbContext.KnowledgeUnitLinks
|
||||
var links = await _dbContext.KnowledgeUnitLinks
|
||||
.AsNoTracking()
|
||||
.Where(l => candidateIds.Contains(l.SourceUnitId) && (l.RelationType == "Defines" || l.RelationType == "Next"))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var relatedIds = links.Select(l => l.TargetUnitId).Distinct().ToList();
|
||||
var relatedUnits = await dbContext.KnowledgeUnits
|
||||
var relatedUnits = await _dbContext.KnowledgeUnits
|
||||
.AsNoTracking()
|
||||
.Where(u => relatedIds.Contains(u.Id))
|
||||
.ToDictionaryAsync(u => u.Id, cancellationToken);
|
||||
|
||||
@@ -2,19 +2,16 @@ using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NexusReader.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Application.Security.Authorization;
|
||||
|
||||
public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly UserManager<NexusUser> _userManager;
|
||||
|
||||
public ProUserHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
public ProUserHandler(UserManager<NexusUser> userManager)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
protected override async Task HandleRequirementAsync(
|
||||
@@ -27,18 +24,14 @@ public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = _dbContextFactory.CreateDbContext();
|
||||
var user = await db.Users
|
||||
.Include(u => u.SubscriptionPlan)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
if (user == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Rule 1: Unlimited access
|
||||
if (user.SubscriptionPlan?.IsUnlimitedTokens == true)
|
||||
// Rule 1: Explicit Pro plan
|
||||
if (user.SubscriptionPlanId == SubscriptionPlan.ProId)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
|
||||
Generated
-659
@@ -1,659 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260506184227_UpdateSubscriptionPlanIsUnlimitedTokens")]
|
||||
partial class UpdateSubscriptionPlanIsUnlimitedTokens
|
||||
{
|
||||
/// <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<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
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 = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<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
|
||||
}
|
||||
}
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateSubscriptionPlanIsUnlimitedTokens : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsUnlimitedTokens",
|
||||
table: "SubscriptionPlans",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SubscriptionPlans",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "AITokenLimit", "IsUnlimitedTokens", "StripeProductId" },
|
||||
values: new object[] { 5000, false, "prod_Free789" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SubscriptionPlans",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "IsUnlimitedTokens",
|
||||
value: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SubscriptionPlans",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "IsUnlimitedTokens",
|
||||
value: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SubscriptionPlans",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
columns: new[] { "AITokenLimit", "IsUnlimitedTokens" },
|
||||
values: new object[] { 1000000000, true });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsUnlimitedTokens",
|
||||
table: "SubscriptionPlans");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SubscriptionPlans",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "AITokenLimit", "StripeProductId" },
|
||||
values: new object[] { 1000, "" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SubscriptionPlans",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
column: "AITokenLimit",
|
||||
value: 500000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -23,8 +23,6 @@ public class SubscriptionPlan
|
||||
|
||||
public int AITokenLimit { get; set; }
|
||||
|
||||
public bool IsUnlimitedTokens { get; set; }
|
||||
|
||||
public decimal MonthlyPrice { get; set; }
|
||||
|
||||
[MaxLength(50)]
|
||||
|
||||
@@ -5,8 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using GeminiDotnet;
|
||||
using GeminiDotnet.Extensions.AI;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Infrastructure.Services;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
|
||||
@@ -4,28 +4,27 @@ using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Commands.Sync;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Infrastructure.RealTime;
|
||||
|
||||
namespace NexusReader.Infrastructure.Handlers;
|
||||
|
||||
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IHubContext<SyncHub> _hubContext;
|
||||
|
||||
public UpdateReadingProgressCommandHandler(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
AppDbContext context,
|
||||
IHubContext<SyncHub> hubContext)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_context = context;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Fail("User not found.");
|
||||
@@ -35,7 +34,7 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReading
|
||||
user.LastReadPageId = request.PageId;
|
||||
user.LastReadAt = now;
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Broadcast to other devices
|
||||
await _hubContext.Clients
|
||||
|
||||
@@ -2,8 +2,7 @@ using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Identity;
|
||||
|
||||
@@ -12,12 +11,12 @@ namespace NexusReader.Infrastructure.Identity;
|
||||
/// </summary>
|
||||
public class TokenLimitHandler : AuthorizationHandler<TokenLimitRequirement>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly AppDbContext _dbContext;
|
||||
private readonly UserManager<NexusUser> _userManager;
|
||||
|
||||
public TokenLimitHandler(IDbContextFactory<AppDbContext> dbContextFactory, UserManager<NexusUser> userManager)
|
||||
public TokenLimitHandler(AppDbContext dbContext, UserManager<NexusUser> userManager)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbContext = dbContext;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
@@ -31,18 +30,14 @@ public class TokenLimitHandler : AuthorizationHandler<TokenLimitRequirement>
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = _dbContextFactory.CreateDbContext();
|
||||
var user = await db.Users
|
||||
.Include(u => u.SubscriptionPlan)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
if (user == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has available tokens or unlimited plan
|
||||
if (user.SubscriptionPlan?.IsUnlimitedTokens == true || user.AITokensUsed < user.AITokenLimit)
|
||||
// Check if user has available tokens
|
||||
if (user.AITokensUsed < user.AITokenLimit)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260428184727_InitialPostgres")]
|
||||
+1
-1
@@ -4,7 +4,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialPostgres : Migration
|
||||
+2
-2
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260428185239_IncreaseHashLength")]
|
||||
+1
-1
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class IncreaseHashLength : Migration
|
||||
+2
-2
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260429080302_AddQuizResults")]
|
||||
+1
-1
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQuizResults : Migration
|
||||
+2
-2
@@ -4,13 +4,13 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260503175906_FinalNormalizedSubscriptionArchitecture")]
|
||||
+1
-1
@@ -7,7 +7,7 @@ using Pgvector;
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FinalNormalizedSubscriptionArchitecture : Migration
|
||||
+11
-18
@@ -3,13 +3,13 @@ using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
namespace NexusReader.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
@@ -200,7 +200,7 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks", (string)null);
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
@@ -246,7 +246,7 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits", (string)null);
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
@@ -278,7 +278,7 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks", (string)null);
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
@@ -413,7 +413,7 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults", (string)null);
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
@@ -458,7 +458,7 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache", (string)null);
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
@@ -472,9 +472,6 @@ namespace NexusReader.Data.Migrations
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
@@ -493,23 +490,21 @@ namespace NexusReader.Data.Migrations
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans", (string)null);
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
AITokenLimit = 1000,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
StripeProductId = ""
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
@@ -518,7 +513,6 @@ namespace NexusReader.Data.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
@@ -526,8 +520,7 @@ namespace NexusReader.Data.Migrations
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
AITokenLimit = 500000,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.Data\NexusReader.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+9
-16
@@ -1,23 +1,16 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
namespace NexusReader.Data.Persistence;
|
||||
|
||||
public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
base.OnConfiguring(optionsBuilder);
|
||||
// Suppress the pending model changes warning to avoid runtime exceptions in some environments
|
||||
optionsBuilder.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
|
||||
}
|
||||
|
||||
public DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache => Set<SemanticKnowledgeCache>();
|
||||
public DbSet<KnowledgeUnit> KnowledgeUnits => Set<KnowledgeUnit>();
|
||||
public DbSet<KnowledgeUnitLink> KnowledgeUnitLinks => Set<KnowledgeUnitLink>();
|
||||
@@ -27,8 +20,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
|
||||
modelBuilder.Entity<NexusUser>(entity =>
|
||||
@@ -47,6 +38,8 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
.HasDefaultValue(1);
|
||||
});
|
||||
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
||||
{
|
||||
entity.HasIndex(p => p.PlanName).IsUnique();
|
||||
@@ -104,10 +97,10 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
|
||||
// Seed Subscription Plans with deterministic IDs
|
||||
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
||||
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
|
||||
new SubscriptionPlan { Id = 2, PlanName = SubscriptionPlan.BasicName, AITokenLimit = 10000, IsUnlimitedTokens = false, MonthlyPrice = 9.99m, StripeProductId = "prod_basic_placeholder" },
|
||||
new SubscriptionPlan { Id = 3, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, IsUnlimitedTokens = false, MonthlyPrice = 19.99m, StripeProductId = "prod_pro_placeholder" },
|
||||
new SubscriptionPlan { Id = 4, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 1000000000, IsUnlimitedTokens = true, MonthlyPrice = 99.99m, StripeProductId = "prod_enterprise_placeholder" }
|
||||
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" }
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
|
||||
namespace NexusReader.Data.Persistence;
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||
{
|
||||
+21
-25
@@ -7,16 +7,16 @@ using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace NexusReader.Data.Persistence;
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
public static class DbInitializer
|
||||
{
|
||||
public static async Task SeedAsync(IServiceProvider serviceProvider)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>();
|
||||
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<NexusUser>>();
|
||||
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -27,9 +27,9 @@ public static class DbInitializer
|
||||
{
|
||||
dbContext.SubscriptionPlans.AddRange(new List<SubscriptionPlan>
|
||||
{
|
||||
new SubscriptionPlan { Id = SubscriptionPlan.FreeId, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0, StripeProductId = "prod_Free789" },
|
||||
new SubscriptionPlan { Id = SubscriptionPlan.ProId, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, IsUnlimitedTokens = false, MonthlyPrice = 19, StripeProductId = "prod_Pro123" },
|
||||
new SubscriptionPlan { Id = SubscriptionPlan.EnterpriseId, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 1000000000, IsUnlimitedTokens = true, MonthlyPrice = 99, StripeProductId = "prod_Enterprise456" }
|
||||
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.");
|
||||
@@ -39,47 +39,43 @@ public static class DbInitializer
|
||||
string[] roleNames = { "Admin", "User" };
|
||||
foreach (var roleName in roleNames)
|
||||
{
|
||||
var roleExist = dbContext.Roles.Any(r => r.Name == roleName);
|
||||
var roleExist = await roleManager.RoleExistsAsync(roleName);
|
||||
if (!roleExist)
|
||||
{
|
||||
dbContext.Roles.Add(new IdentityRole { Name = roleName, NormalizedName = roleName.ToUpper() });
|
||||
await roleManager.CreateAsync(new IdentityRole(roleName));
|
||||
Console.WriteLine($"[Seeder] Created role: {roleName}");
|
||||
}
|
||||
}
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// Seed Admin User
|
||||
var adminEmail = "admin@nexus.com";
|
||||
var normalizedEmail = adminEmail.ToUpper();
|
||||
var adminUser = await dbContext.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail);
|
||||
var adminUser = await userManager.FindByEmailAsync(adminEmail);
|
||||
|
||||
if (adminUser == null)
|
||||
{
|
||||
adminUser = new NexusUser
|
||||
{
|
||||
UserName = adminEmail,
|
||||
NormalizedUserName = normalizedEmail,
|
||||
Email = adminEmail,
|
||||
NormalizedEmail = normalizedEmail,
|
||||
EmailConfirmed = true,
|
||||
SubscriptionPlanId = SubscriptionPlan.EnterpriseId,
|
||||
AITokenLimit = 1000000,
|
||||
TenantId = Guid.NewGuid().ToString(),
|
||||
SecurityStamp = Guid.NewGuid().ToString()
|
||||
TenantId = Guid.NewGuid().ToString()
|
||||
};
|
||||
|
||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!");
|
||||
|
||||
dbContext.Users.Add(adminUser);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var adminRole = await dbContext.Roles.FirstAsync(r => r.Name == "Admin");
|
||||
dbContext.UserRoles.Add(new IdentityUserRole<string> { UserId = adminUser.Id, RoleId = adminRole.Id });
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var createPowerUser = await userManager.CreateAsync(adminUser, "Admin123!");
|
||||
if (createPowerUser.Succeeded)
|
||||
{
|
||||
await userManager.AddToRoleAsync(adminUser, "Admin");
|
||||
Console.WriteLine($"[Seeder] Admin user created successfully: {adminEmail}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var errors = string.Join(", ", createPowerUser.Errors.Select(e => e.Description));
|
||||
Console.WriteLine($"[Seeder] Failed to create admin user: {errors}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[Seeder] Admin user already exists.");
|
||||
}
|
||||
@@ -5,24 +5,24 @@ using Microsoft.Extensions.Options;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
public class BillingService : IBillingService
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly AppDbContext _dbContext;
|
||||
private readonly UserManager<NexusUser> _userManager;
|
||||
private readonly StripeSettings _stripeSettings;
|
||||
private readonly ILogger<BillingService> _logger;
|
||||
|
||||
public BillingService(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
AppDbContext dbContext,
|
||||
UserManager<NexusUser> userManager,
|
||||
IOptions<StripeSettings> stripeSettings,
|
||||
ILogger<BillingService> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbContext = dbContext;
|
||||
_userManager = userManager;
|
||||
_stripeSettings = stripeSettings.Value;
|
||||
_logger = logger;
|
||||
@@ -55,8 +55,7 @@ public class BillingService : IBillingService
|
||||
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
|
||||
}
|
||||
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
|
||||
var plan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
|
||||
if (plan != null)
|
||||
{
|
||||
user.SubscriptionPlanId = plan.Id;
|
||||
@@ -83,8 +82,7 @@ public class BillingService : IBillingService
|
||||
return false;
|
||||
}
|
||||
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var freePlan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
|
||||
var freePlan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
|
||||
if (freePlan != null)
|
||||
{
|
||||
user.SubscriptionPlanId = freePlan.Id;
|
||||
|
||||
@@ -7,7 +7,7 @@ using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Helpers;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using Polly;
|
||||
using Polly.Registry;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -18,7 +18,39 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.groundedness-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.groundedness-badge.status-high {
|
||||
color: var(--nexus-neon);
|
||||
border-color: var(--nexus-neon);
|
||||
}
|
||||
|
||||
.groundedness-badge.status-medium {
|
||||
color: #ffaa00;
|
||||
border-color: #ffaa00;
|
||||
}
|
||||
|
||||
.groundedness-badge.status-low {
|
||||
color: #ff4444;
|
||||
border-color: #ff4444;
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Answer { get; set; } = string.Empty;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
.groundedness-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.groundedness-badge.status-high {
|
||||
color: var(--nexus-neon);
|
||||
border-color: var(--nexus-neon);
|
||||
}
|
||||
|
||||
.groundedness-badge.status-medium {
|
||||
color: #ffaa00;
|
||||
border-color: #ffaa00;
|
||||
}
|
||||
|
||||
.groundedness-badge.status-low {
|
||||
color: #ff4444;
|
||||
border-color: #ff4444;
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
rgba(255, 255, 255, 0) 30%,
|
||||
rgba(255, 255, 255, 0.3) 50%,
|
||||
rgba(255, 255, 255, 0) 70%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer-move 2s infinite linear;
|
||||
display: inline-block;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
@keyframes shimmer-move {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
@@ -59,13 +59,6 @@
|
||||
<div class="auth-error">@_errorMessage</div>
|
||||
}
|
||||
|
||||
<div class="auth-options">
|
||||
<label class="remember-me">
|
||||
<InputCheckbox @bind-Value="_loginModel.RememberMe" />
|
||||
<span>Zapamiętaj mnie</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit-auth" disabled="@_isSubmitting">
|
||||
@if (_isSubmitting)
|
||||
{
|
||||
@@ -90,30 +83,11 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? ErrorCode { get; set; }
|
||||
|
||||
private LoginModel _loginModel = new();
|
||||
private string? _errorMessage;
|
||||
private bool _isSubmitting;
|
||||
private bool _showPassword;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ErrorCode))
|
||||
{
|
||||
_errorMessage = ErrorCode switch
|
||||
{
|
||||
"ExternalLoginFailed" => "Nie udało się zalogować przez Google. Spróbuj ponownie.",
|
||||
"ProvisioningFailed" => "Wystąpił błąd podczas przygotowywania Twojego konta.",
|
||||
"UserAlreadyExists" => "Użytkownik o tym adresie e-mail już istnieje. Zaloguj się tradycyjnie hasłem.",
|
||||
"LockedOut" => "Twoje konto zostało zablokowane. Spróbuj ponownie później.",
|
||||
_ => "Wystąpił nieoczekiwany błąd podczas logowania."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleLogin()
|
||||
{
|
||||
_isSubmitting = true;
|
||||
@@ -121,7 +95,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe);
|
||||
var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password);
|
||||
if (success) NavigationManager.NavigateTo("/");
|
||||
else _errorMessage = "Nieprawidłowy e-mail lub hasło.";
|
||||
}
|
||||
@@ -140,7 +114,5 @@
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
public bool RememberMe { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace NexusReader.UI.Shared.Services;
|
||||
public interface IIdentityService
|
||||
{
|
||||
Task<bool> RegisterAsync(string email, string password);
|
||||
Task<bool> LoginAsync(string email, string password, bool rememberMe = false);
|
||||
Task<bool> LoginAsync(string email, string password);
|
||||
Task LogoutAsync();
|
||||
Task<UserProfile?> GetProfileAsync();
|
||||
Task<bool> RefreshTokenAsync();
|
||||
@@ -45,7 +45,7 @@ public class IdentityService : IIdentityService
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> LoginAsync(string email, string password, bool rememberMe = false)
|
||||
public async Task<bool> LoginAsync(string email, string password)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/login", new { email, password });
|
||||
|
||||
@@ -59,22 +59,7 @@ public class IdentityService : IIdentityService
|
||||
{
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
|
||||
}
|
||||
|
||||
// Option A: Fetch profile to get claims
|
||||
var profile = await GetProfileAsync();
|
||||
if (profile != null)
|
||||
{
|
||||
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
|
||||
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
|
||||
|
||||
_authStateProvider.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback if profile fetch fails
|
||||
_authStateProvider.NotifyUserAuthentication(email, "unknown");
|
||||
}
|
||||
|
||||
_authStateProvider.NotifyUserAuthentication(result.AccessToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -86,8 +71,6 @@ public class IdentityService : IIdentityService
|
||||
{
|
||||
_storageService.RemoveSecure(TokenKey);
|
||||
_storageService.RemoveSecure(RefreshTokenKey);
|
||||
_storageService.RemoveSecure("nexus_user_email");
|
||||
_storageService.RemoveSecure("nexus_user_tenant");
|
||||
_authStateProvider.NotifyUserLogout();
|
||||
}
|
||||
|
||||
@@ -122,15 +105,7 @@ public class IdentityService : IIdentityService
|
||||
{
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, loginResult.RefreshToken);
|
||||
}
|
||||
|
||||
var profile = await GetProfileAsync();
|
||||
if (profile != null)
|
||||
{
|
||||
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
|
||||
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
|
||||
_authStateProvider.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
|
||||
_authStateProvider.NotifyUserAuthentication(loginResult.AccessToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
public sealed class KnowledgeCoordinator : IDisposable
|
||||
{
|
||||
private readonly IKnowledgeService _knowledgeService;
|
||||
private readonly IKnowledgeGraphService _graphService;
|
||||
@@ -46,7 +46,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullContent)) return;
|
||||
|
||||
LogGeneratingGraph(tenantId);
|
||||
_logger.LogInformation("[KnowledgeCoordinator] Generating full page graph for tenant: {TenantId}", tenantId);
|
||||
|
||||
_graphService.Clear();
|
||||
_graphService.SetLoading(true);
|
||||
@@ -67,7 +67,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogGraphError(ex, tenantId);
|
||||
_logger.LogError(ex, "[KnowledgeCoordinator] Error generating graph for tenant: {TenantId}. Message: {ErrorMessage}", tenantId, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
|
||||
{
|
||||
_quizService.SetHydrating(true);
|
||||
LogRequestingSummary(tenantId);
|
||||
_logger.LogInformation("[KnowledgeCoordinator] Requesting summary and quiz for tenant: {TenantId}", tenantId);
|
||||
try
|
||||
{
|
||||
var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId);
|
||||
@@ -96,11 +96,11 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
return packet;
|
||||
}
|
||||
|
||||
LogSummaryWarning(tenantId);
|
||||
_logger.LogWarning("[KnowledgeCoordinator] Failed to get summary and quiz for tenant: {TenantId}", tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogSummaryError(ex, tenantId);
|
||||
_logger.LogError(ex, "[KnowledgeCoordinator] Error requesting summary and quiz for tenant: {TenantId}. Message: {ErrorMessage}", tenantId, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -119,19 +119,4 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
{
|
||||
_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);
|
||||
}
|
||||
|
||||
@@ -19,30 +19,15 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
||||
var token = tokenResult.IsSuccess ? tokenResult.Value : null;
|
||||
var result = await _storageService.GetSecureString(TokenKey);
|
||||
var token = result.IsSuccess ? result.Value : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
|
||||
// For opaque tokens, we read the user info that was stored during login
|
||||
var emailResult = await _storageService.GetSecureString("nexus_user_email");
|
||||
var tenantIdResult = await _storageService.GetSecureString("nexus_user_tenant");
|
||||
|
||||
var claims = new List<Claim>();
|
||||
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Name, emailResult.Value));
|
||||
claims.Add(new Claim(ClaimTypes.Email, emailResult.Value));
|
||||
}
|
||||
if (tenantIdResult.IsSuccess && !string.IsNullOrEmpty(tenantIdResult.Value))
|
||||
{
|
||||
claims.Add(new Claim("TenantId", tenantIdResult.Value));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "OpaqueBearer");
|
||||
var identity = new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt");
|
||||
var user = new ClaimsPrincipal(identity);
|
||||
|
||||
return new AuthenticationState(user);
|
||||
@@ -53,16 +38,9 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyUserAuthentication(string email, string tenantId)
|
||||
public void NotifyUserAuthentication(string token)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.Name, email),
|
||||
new Claim(ClaimTypes.Email, email),
|
||||
new Claim("TenantId", tenantId)
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "OpaqueBearer");
|
||||
var identity = new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt");
|
||||
var user = new ClaimsPrincipal(identity);
|
||||
var authState = Task.FromResult(new AuthenticationState(user));
|
||||
NotifyAuthenticationStateChanged(authState);
|
||||
@@ -74,4 +52,30 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
var authState = Task.FromResult(new AuthenticationState(guest));
|
||||
NotifyAuthenticationStateChanged(authState);
|
||||
}
|
||||
|
||||
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
|
||||
{
|
||||
var claims = new List<Claim>();
|
||||
var payload = jwt.Split('.')[1];
|
||||
|
||||
var jsonBytes = ParseBase64WithoutPadding(payload);
|
||||
var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
|
||||
|
||||
if (keyValuePairs != null)
|
||||
{
|
||||
claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString() ?? string.Empty)));
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
private byte[] ParseBase64WithoutPadding(string base64)
|
||||
{
|
||||
switch (base64.Length % 4)
|
||||
{
|
||||
case 2: base64 += "=="; break;
|
||||
case 3: base64 += "="; break;
|
||||
}
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,28 +74,6 @@
|
||||
|
||||
.toggle-visibility { position: absolute; right: 16px; background: none; border: none; color: var(--nexus-text-muted); cursor: pointer; padding: 4px; z-index: 5; }
|
||||
|
||||
.auth-options {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nexus-text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.remember-me input {
|
||||
accent-color: var(--nexus-primary);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.btn-submit-auth {
|
||||
width: 100%; padding: 14px; background: var(--nexus-primary); border: none; border-radius: 12px;
|
||||
color: #000; font-size: 0.95rem; font-weight: 700; cursor: pointer; transition: all 0.2s;
|
||||
|
||||
@@ -6,7 +6,7 @@ using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Web.Client.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -135,8 +135,7 @@ using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var services = scope.ServiceProvider;
|
||||
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||
var dbContextFactory = services.GetRequiredService<IDbContextFactory<NexusReader.Data.Persistence.AppDbContext>>();
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
var dbContext = services.GetRequiredService<NexusReader.Infrastructure.Persistence.AppDbContext>();
|
||||
|
||||
int maxRetries = 5;
|
||||
int delayMs = 2000;
|
||||
@@ -355,57 +354,31 @@ app.MapGet("/identity/login/google", (string? returnUrl) =>
|
||||
app.MapGet("/identity/callback/google", async (
|
||||
HttpContext context,
|
||||
SignInManager<NexusUser> signInManager,
|
||||
UserManager<NexusUser> userManager,
|
||||
ILogger<Program> logger) =>
|
||||
UserManager<NexusUser> userManager) =>
|
||||
{
|
||||
var info = await signInManager.GetExternalLoginInfoAsync();
|
||||
if (info == null)
|
||||
{
|
||||
logger.LogWarning("External login info from Google is null.");
|
||||
return Results.Redirect("/account/login?error=ExternalLoginFailed");
|
||||
}
|
||||
if (info == null) return Results.Redirect("/account/login?error=ExternalLoginFailed");
|
||||
|
||||
var result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
logger.LogInformation("User logged in via Google: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email));
|
||||
return Results.Redirect("/");
|
||||
}
|
||||
|
||||
if (result.IsLockedOut)
|
||||
{
|
||||
logger.LogWarning("User account locked out during Google login: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email));
|
||||
return Results.Redirect("/account/login?error=LockedOut");
|
||||
}
|
||||
|
||||
// New user provisioning
|
||||
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
|
||||
if (email != null)
|
||||
{
|
||||
var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true };
|
||||
var createResult = await userManager.CreateAsync(user);
|
||||
|
||||
if (createResult.Succeeded)
|
||||
{
|
||||
await userManager.AddLoginAsync(user, info);
|
||||
await signInManager.SignInAsync(user, isPersistent: false);
|
||||
logger.LogInformation("New user provisioned via Google: {Email}", email);
|
||||
return Results.Redirect("/");
|
||||
}
|
||||
|
||||
// Log specific errors
|
||||
foreach (var error in createResult.Errors)
|
||||
{
|
||||
logger.LogError("Google provisioning failed for {Email}: {Code} - {Description}", email, error.Code, error.Description);
|
||||
}
|
||||
|
||||
if (createResult.Errors.Any(e => e.Code == "DuplicateEmail" || e.Code == "DuplicateUserName"))
|
||||
{
|
||||
return Results.Redirect("/account/login?error=UserAlreadyExists");
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogError("Google provisioning failed - unknown reason for email {Email}", email);
|
||||
return Results.Redirect("/account/login?error=ProvisioningFailed");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user