43 Commits

Author SHA1 Message Date
mjasin 75c7b2f279 feat: implement interactive citation markers with metadata and optimize knowledge caching with concurrent collision handling 2026-05-26 13:43:05 +02:00
mjasin 824b4366e0 feat: implement Neo4j knowledge graph synchronization and integrate global cache support with custom tenant claims. 2026-05-26 12:54:41 +02:00
mjasin 3cbbb6df6b fix(knowledge-service): resolve semantic cache collision by partitioning content hash by traceType and PromptVersion 2026-05-25 11:31:19 +02:00
mjasin f8d1ceabd3 feat(ui/quiz): implement real-time global chapter quiz generation, submit results to database, and display dynamic statistics on dashboard 2026-05-25 10:23:38 +02:00
mjasin 1c6ee82d01 feat: implement background ebook indexing with progress tracking and real-time UI updates 2026-05-25 08:51:21 +02:00
mjasin 39717725ec style(ui): align global Q&A search component styling with dashboard glassmorphism and neon green theme 2026-05-23 20:33:15 +02:00
mjasin 9d396570aa fix(rag): retrieve dynamic tenantId instead of hardcoded literal in global Q&A 2026-05-23 20:30:11 +02:00
mjasin d78abd0c4d style(ui): customize NexusSearchBox styling to perfectly match dashboard glassmorphism and var(--nexus-neon) tokens 2026-05-23 20:19:04 +02:00
mjasin 97c1c309b1 feat(rag): implement Qdrant dynamic collection creation, deterministic ID matching, and batch vector ingestion 2026-05-23 20:17:41 +02:00
mjasin 5740d9126a feat(maui): resolve 401 load error by registering MobileAuthenticationHeaderHandler with configuration-based API host 2026-05-21 20:32:11 +02:00
mjasin f902073bcb feat(maui): implement unified Serilog logging infrastructure and Blazor/JS interop bridge 2026-05-21 20:25:32 +02:00
mjasin 0a3ca77d46 feat(ui): implement premium NexusSearchBox component and integrate semantic search navigation 2026-05-21 20:16:14 +02:00
Antigravity 37bec89484 feat: implement central package management and stabilize mobile build (#50)
This pull request implements **Central Package Management (CPM)** across the NexusReader solution to centralize package version definitions, improve package maintainability, and ensure security patch consistency. It also resolves compile issues in the mobile infrastructure and client projects.

### Key Changes

#### 1. NuGet Central Package Management (CPM)
- Created `Directory.Packages.props` in the solution root containing all solution-wide dependency versions (consolidating 48 packages).
- Pinned and secured `Microsoft.Bcl.Memory` to `v9.0.14` to resolve a known high-severity vulnerability (CVE-2026-26127).
- Stripped explicit `Version` attributes from `.csproj` files for the core library, web client, web host, UI shared, data access, and testing projects to inherit central version definitions.

#### 2. Mobile / MAUI Projects Stabilization
- **Workload Support & Locally Disabled CPM**: Disabled Central Package Management locally (`<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>`) in both `NexusReader.Infrastructure.Mobile.csproj` and `NexusReader.Maui.csproj` to preserve native MAUI workload package integration while cleanly referencing package versions manually.
- **Ambiguity Resolving**: Added using aliases for `FluentResults.Result` to eliminate compiler ambiguity conflicts between `Android.App.Result` and `FluentResults.Result` inside Android platform service implementations.
- **Missing Namespaces Fix**: Added explicit hosting imports (`using Microsoft.Maui; using Microsoft.Maui.Hosting;`) and ensured `Microsoft.Maui.Essentials` references resolve properly in the mobile context.

---

### Verification
- **Build**: Successfully built the entire solution with zero compilation errors (`dotnet build NexusReader.slnx --no-restore` -> `Liczba błędów: 0`).
- **Tests**: All 7 integration and unit tests run and pass successfully (`dotnet test NexusReader.slnx --no-restore`).

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #50
Reviewed-by: Marek Jaisński <jasins.marek@gmail.com>
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-21 17:42:29 +00:00
Antigravity cb4b7d0052 feat(rag): implement KM-RAG retrieval read-path, API endpoints, global Q&A UI, and unit tests (#49)
This Pull Request implements the complete **Retrieval module (Read Path)** for the Knowledge-Map RAG (KM-RAG) architecture within the NexusReader platform. It resolves all requirements for vector-based semantic search, Neo4j graph context expansion, structured grounding with Google Gemini, API/Wasm integration, and an interactive front-end global Q&A panel.

Resolves #48

### 🚀 Key Implementations

1. **Grounded DTOs & Schema Definition**
   - Added `GroundedResponseDto` and `CitationDto` for strict JSON Schema matching with Gemini.

2. **Core Service & Read Path Logic**
   - Implemented the robust **5-step pipeline** in `KnowledgeService.AskQuestionAsync`:
     1. *Embedding*: Query vectorization using `IEmbeddingGenerator`.
     2. *Semantic Search*: Multi-tenant vector search with Qdrant, supporting scoping to a specific book or global search.
     3. *Graph Expansion*: Fetching connected concepts and parent relationships using Neo4j Cypher.
     4. *Citation Hydration*: Cross-referencing results with PostgreSQL to fetch book titles and accurate chapter citations.
     5. *Grounded Generation*: Strict structured generation via `IChatClient` (Gemini) preventing hallucinations and using citations.

3. **CQRS & Endpoints**
   - Added `AskLibraryQuestionQuery` and its handler.
   - Mapped `/api/knowledge/ask` and `/api/knowledge/search` endpoints inside `Program.cs`.
   - Updated `WasmKnowledgeService` to support proxying retrieval requests.

4. **Premium Blazor UI Panel**
   - Implemented `/intelligence` (Global AI Q&A) with a curated HSL palette, dark theme, smooth micro-animations, loading shimmers, and side-by-side citation cards.
   - Registered the panel within the `MainHubLayout` sidebar.

5. **Test Coverage**
   - Wrote comprehensive xUnit tests in `QueryTests.cs` using Moq and FluentAssertions to assert that handlers correctly validate input and interact with services.

### 🧪 Verification
- Verified compilation and build gate successfully (`dotnet build`: 0 errors).
- All 7 tests passed perfectly (`dotnet test`).

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #49
Reviewed-by: Marek Jaisński <jasins.marek@gmail.com>
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-20 18:29:15 +00:00
Antigravity 23acaeb705 feat: KM-RAG Polyglot Ingestion Pipeline Migration (#46)
Resolves the KM-RAG Polyglot Persistence and Background Ingestion Pipeline Migration task.

### Key Changes
1. **Infrastructure Migration**: Integrated Qdrant (for vector embeddings) and Neo4j (for concept graphs), reducing reliance on PostgreSQL pgvector storage.
2. **Concurrent Background Job**: Implemented a robust Hangfire `EbookIngestionJob` utilizing Polly exponential retries for transient 429 rate limits, executing three core ingestion tasks concurrently via `Task.WhenAll`.
3. **Data Layer**: Standardized database schemas and entities; retained `Pgvector.EntityFrameworkCore` for migration compilation compatibility.
4. **Wasm Client & Tests**: Implemented client support for semantic search and refactored related tests in `QueryTests.cs` to mock `IKnowledgeService`.

### Verification Status
- **Build**: Successfully compiles with `dotnet build NexusReader.slnx --no-restore` (0 errors).
- **Tests**: All 5 unit tests pass cleanly with `dotnet test NexusReader.slnx --no-restore`.

**Resolve** #47

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #46
Reviewed-by: Marek Jaisński <jasins.marek@gmail.com>
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-20 18:15:28 +00:00
Antigravity 711822f5de fix(ui/security): Enforce idempotent AI fetching, secure auth handler, and memory leak guards (#45)
This PR provides critical stabilization, memory leak resolution, and security enhancements for the NexusReader application, specifically focusing on Blazor InteractiveAuto lifecycle safety, thread-safe automated authentication token refresh, and deduplication of active AI service queries.

### Key Enhancements

#### 1. Security & Lifecycle Stabilization (`AuthenticationHeaderHandler.cs` & `Library.razor`)
* **Secure Token Propagation (CWE-200)**: Modified the outbound delegating handler to only append JWT Bearer headers to trusted base origin requests matching the application's configured `NavigationManager.BaseUri`, preventing potential token leakage to external services.
* **Captive Dependency & Memory Leak Fix (CWE-400)**: Avoided capturing scoped dependencies in a singleton handler by wrapping the resolution of `IIdentityService` inside a dedicated, disposable `IServiceProvider` scope (`_serviceProvider.CreateScope()`).
* **Thread-Safe Automated Refresh**: Embedded a `SemaphoreSlim` lock around the automated `RefreshTokenAsync` renewal sequence to handle concurrent API requests gracefully without triggering duplicate token refresh attempts.
* **Pre-rendering Safety**: Deferred the secure book loading query in `Library.razor` from `OnInitializedAsync` to client-side `OnAfterRenderAsync(firstRender: true)` to avoid inevitable `401 Unauthorized` responses and logs during the server pre-rendering phase.

#### 2. Robust AI Request Deduplication (`KnowledgeService.cs`)
* **State Recovery Guards**: Enhanced the thread-safe `Lazy<Task<Result<KnowledgePacket>>>` deduplication map by adding thorough failure handling blocks. Active requests are guaranteed to be cleaned up (`TryRemove`) inside `finally` and failed results pathways, ensuring future retries can run immediately if an initial request encounters an error.

#### 3. Idempotent AI UI Fetching & JSRuntime Guards
* **Interactive Guards**: Added an `_isInteractive` check to `GroundednessBadge.razor` and `AiAssistantBubble.razor` components, deferring WebAssembly API executions and DOM updates to client-side `OnAfterRenderAsync`.
* **State Synchronization**: Integrated a synchronous `OnParametersSet` to properly reset groundedness badges when content changes.
* **Flicker Elimination**: Moved JSRuntime local-storage checks in `Home.razor` (for focus mode preferences) to `OnAfterRenderAsync(firstRender: true)`, resolving startup JSInterop exceptions and eliminating layout shifts.

### Verification Performed
* Mandatory build gate verified: `Kompilacja powiodła się.` with zero compile errors (`dotnet build NexusReader.slnx --no-restore`).
* Validated dependency resolution patterns and async safety (no `async void`).

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #45
Reviewed-by: Marek Jaisński <jasins.marek@gmail.com>
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-20 17:27:39 +00:00
Antigravity 541e9e1fb5 feat(ai-ux): deduplicate AI queries, handle ServiceUnavailable retries, and optimize reader canvas graph prerendering (#44)
This Pull Request encapsulates all outstanding AI, Blazor InteractiveAuto lifecycle, pgvector, and Firefox authorization/session compatibility fixes.

### Key Accomplishments:
1. **Concurrent Request Deduplication (Option B):** Implemented a thread-safe active task registry in `KnowledgeService` that groups concurrent graph extraction queries for the same content, preventing duplicate AI calls completely.
2. **Resilience Strategy for Downstream Demands:** Extended the `ai-retry` resilience pipeline to automatically intercept and retry on temporary Google API `503 ServiceUnavailable` / `high demand` spikes.
3. **Interactive Graph Generation Guard (Option A):** Prevented server-side prerender-phase graph requests in the reader canvas component.
4. **Firefox Compatibility & Cookie Handler:** Implemented an authentication endpoint and hybrid hidden-form submission flow to solve login, registration, and logout redirections and cookies securely.
5. **Autoscrolling & Graph Exclusions:** Added concept-to-block smooth scrolling, active block badging, and filtered out markdown code blocks from being extracted as nodes.

All unit tests compiled and passed 100% cleanly.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #44
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-18 17:53:36 +00:00
Antigravity f808734768 feat: establish formal relationship between KnowledgeUnit and Ebook (#35) (#43)
This PR finalizes the implementation of issue #35 by establishing a formal foreign key relationship between `KnowledgeUnit` and `Ebook`.

Closes #35

### Changes:
- **Domain**: Refactored `KnowledgeUnit` to use `Guid EbookId` and added navigation property.
- **Data**: Updated `AppDbContext` fluent configuration and generated a new migration.
- **Service**: Updated `IKnowledgeService` and its implementations to propagate `ebookId`.
- **API**: Updated Web API endpoints to support linking extracted knowledge to specific ebooks.

### Verification:
- [x] Solution builds successfully (`dotnet build`).
- [x] Schema changes verified in migration file.
- [x] Cascading delete behavior confirmed.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #43
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-14 18:17:16 +00:00
Antigravity 5a2223a4c8 feat: Ingestion Pipeline Stabilization and WASM Service Proxies (#42)
This PR stabilizes the Nexus Ingestion Engine by implementing functional service proxies for the Blazor WASM client and refining the backend infrastructure for real-time progress tracking and database compatibility.

### Key Changes
- **Infrastructure Stabilization**:
  - Implemented production-grade `EbookRepository` with PostgreSQL `EF.Functions.ILike` support.
  - Enforced `IsReadyForReading = false` state for newly added ebooks (resolves #35).
  - Updated `SignalRSyncBroadcaster` to support targeted user messaging and ingestion-specific progress updates (resolves #37).
- **WASM Client Functional Proxies**:
  - Replaced "Throwing" dummy services with `WasmEbookRepository`, `WasmSyncBroadcaster`, `WasmBookStorageService`, and `WasmEmbeddingGenerator`.
  - These services proxy requests to the backend via a new set of Minimal API endpoints in `NexusReader.Web`.
- **Domain Refinement**:
  - Added `IsReadyForReading` flag to the `Ebook` entity to manage background AI processing states.

### Related Issues
- Fixes #35
- Fixes #36
- Fixes #37

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #42
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-13 18:24:24 +00:00
Antigravity d5c2952bec feat(ingestion): implement hybrid metadata verification form #34 (#41)
### Description
This PR implements **Issue #34: [UI/UX] Implement Hybrid Metadata Verification Form in Ingestion Modal**.

### Key Changes
- **Metadata Verification State**: Introduced a new state in `BookIngestionModal.razor` allowing users to edit `Title` and `Author` before final ingestion.
- **Cover Image Preview**: Added a high-fidelity cover preview with a CSS-based glowing placeholder fallback for books without embedded covers.
- **Ingestion Pipeline**:
  - Implemented `IngestEbookCommand` and `IngestEbookCommandHandler`.
  - Added `IBookStorageService` and its implementation for managing EPUB and cover file storage.
  - Exposed `POST /api/library/ingest` Minimal API endpoint with `.DisableAntiforgery()` to handle client-side JSON uploads.
- **Stability Fixes**:
  - Resolved DI validation errors in the WASM client by providing a dummy `IBookStorageService` registration.
  - Adjusted Kestrel request limits to handle large EPUB payloads (up to 100MB).
  - Corrected middleware ordering to ensure Antiforgery works correctly with Authentication.

### Verification
- Solution builds successfully.
- Manual verification of modal state transitions and API ingestion logic.

Closes #34.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #41
Reviewed-by: Marek Jaisński <jasins.marek@gmail.com>
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-12 18:19:07 +00:00
Antigravity fe5ff81c98 Refactor: Web Consolidation and Identity Stabilization (#40)
## Overview
This PR completes the architectural consolidation of the web project and stabilizes the Identity-based authentication flow for the NexusReader application. It also refines the UI aesthetic for the Book Ingestion Modal as requested in #33.

## Key Changes
- **Project Consolidation**: Fully merged `NexusReader.Web.New` into `NexusReader.Web`. This includes updating all namespace references, VS Code launch/task configurations, and CI/CD (`Dockerfile`).
- **Identity Stabilization**:
  - Implemented `IIdentityService` on the server using `SignInManager<NexusUser>` and `UserManager<NexusUser>`.
  - Fixed registration logic to include mandatory fields (`SubscriptionPlanId`, `TenantId`).
  - Updated `Login.razor` to force a page reload on successful login, ensuring proper synchronization of authentication cookies between SignalR and the browser.
- **UI/UX Refinement**:
  - Updated `BookIngestionModal` styling to follow the **Nexus Neon** design system.
  - Added premium button styles with hover effects and glows.
  - Improved modal layout and interaction feedback (shimmer effects, spinner colors).
- **Cleanup**: Removed obsolete interfaces and constants that were superseded by newer Application layer implementations.

## Verification
- Successfully built the solution: `dotnet build NexusReader.slnx --no-restore`
- Verified project structure and file moves.
- Validated server-side authentication logic.

Fixes #33

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #40
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-11 19:16:30 +00:00
mjasin f433e3c74a feat: add git workflow standards and update code review guidelines 2026-05-10 19:55:09 +02:00
Antigravity 9a45a078a6 feat(ui/auth): implement authorized 'Add Book' trigger in Library view #32 (#38)
Resolves #32

### Changes:
- Added '[+] Add New Book' button to `Library.razor`.
- Wrapped the button in `AuthorizeView` for `Admin` and `ContentManager` roles.
- Updated `NexusButton` styling to follow standards: secondary gray background with neon glow on hover/focus.
- Refined `Library.razor` layout with a header and glassmorphism panel.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #38
Reviewed-by: Marek Jaisński <jasins.marek@gmail.com>
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-10 17:45:25 +00:00
mjasin 2e23a032d3 feat(ui): Hub Navigation, Profile Dashboard and Auth Stability Fixes (#31)
This PR implements the Hub Navigation system and the Profile Dashboard, while resolving critical session synchronization issues.

### Key Changes
- **Hub Navigation**: Introduced `MainHubLayout` with a premium glassmorphism sidebar, providing access to Dashboard, Library, Concepts Map, and Profile.
- **Profile Dashboard**: Implemented a high-fidelity Profile page (#27) with learning metrics, AI token usage tracking, and system rank visualization.
- **Stability Fixes**:
    - Resolved an infinite network loop on the `/profile` page by implementing request deduplication and in-memory caching in `IdentityService`.
    - Added environment-aware guards to prevent illegal JavaScript interop calls during server-side prerendering.
    - Implemented automatic session invalidation on `401 Unauthorized` responses to handle stale authentication states gracefully.
- **Reader Integration**: Added a "Return to Dashboard" option in the reader toolbar (#26).

Closes #26
Closes #27

Reviewed-on: #31
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
2026-05-10 17:36:35 +00:00
mjasin 34794db209 feat(ui/graph): Knowledge Graph Refinement and Sidebar Hierarchy (#25)
This PR addresses several UI/UX and architectural refinements for the Knowledge Graph and Intelligence Sidebar.

### Key Changes:
- **Knowledge Graph (#21, #22)**:
  - Implemented \"pill-shaped\" nodes with dynamic label truncation and SVG tooltips.
  - Added bound-constrained simulation to keep nodes within the viewport.
  - Integrated `ResizeObserver` for dynamic layout handling.
  - Implemented Zoom-to-Fit functionality.
  - Enforced unique concept IDs in AI prompts and hardened JS logic to prevent multi-selection bugs.
- **Intelligence Sidebar (#23)**:
  - Improved visual depth with a radial gradient background for the graph.
  - Increased sidebar divider contrast for better layering.
  - Transformed graph controls into a floating glassmorphism panel.
  - Relocated the \"Logout\" action to the toolbar bottom and rebranded it as \"Exit\".

Fixes #21
Fixes #22
Fixes #23

Reviewed-on: #25
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
2026-05-09 09:36:23 +00:00
mjasin 9e77aee231 Refactor Intelligence Toolbar (#14) and fix auth regression (#24)
This PR resolves the authentication regression issue where users encountered "Unauthorized" errors after logging out and back in. This regression was identified during the refactoring of the Intelligence Toolbar.

Fixes #14

### Changes:
- **WASM Client**: Added `AuthenticationHeaderHandler` to automatically attach Bearer tokens to `HttpClient` requests.
- **Server**: Configured Cookie authentication to return `401 Unauthorized` for `/api` requests instead of redirecting to the login page.
- **Project Configuration**: Added `Microsoft.Extensions.Http` to the WASM client project to support `IHttpClientFactory` and message handlers.

Verified with local build.

Reviewed-on: #24
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
2026-05-08 18:50:15 +00:00
mjasin 55cc3ae10d feat(ui/arch): Optimize Graph Dynamics, Immersive Reader, and Core Stability (#19)
This PR introduces a major optimization of graph dynamics, immersive reading experience, and architectural stabilization.

### 🚀 Key Improvements

- **Knowledge Graph (Fix #16)**:
  - Implemented smooth D3.js transitions using the General Update Pattern.
  - Added "Neon Flash" entry animations and dynamic node dimming for better focus.
- **Immersive Reader (Fix #12)**:
  - Standardized centered layout (`max-width: 800px`) with **Merriweather** typography.
  - Optimized line-height and letter-spacing for premium readability.
- **Technical Code Blocks (Fix #20)**:
  - High-contrast dark containers for code snippets.
  - **JetBrains Mono** integration and neon-accented scrollbars.
- **Architectural Stabilization**:
  - Enforced a strict **'no async void'** policy in UI services using `Func<Task>`.
  - Resolved WASM runtime DI errors by implementing dummy service proxies for server-side dependencies.
  - Replaced generic 'Not Found' message with a branded Nexus preloader.

Fixes #7, Fixes #12, Fixes #16, Fixes #20.

Reviewed-on: #19
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
2026-05-08 18:16:09 +00:00
mjasin 775fb73fa9 [MN-04] Identity: Fix Google Callback Error Handling and Logging (#18)
This PR implements proper logging and error handling for the Google OAuth callback as requested in issue #3.

### Changes:
- Added `ILogger<Program>` to the Google callback endpoint.
- Logged warning if external login info is null.
- Logged error details from `userManager.CreateAsync` if provisioning fails.
- Introduced specific error codes `UserAlreadyExists` and `LockedOut` to improve UX.
- Updated `Login.razor` to display descriptive Polish error messages based on the query parameter.

Fixes #3

Reviewed-on: #18
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
2026-05-07 17:27:40 +00:00
mjasin 140cf270cc fix(ui): implement shimmer animation for GroundednessBadge (#17)
Reviewed-on: #17
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
2026-05-07 17:17:52 +00:00
mjasin 2248a2b757 fix: migrate to IDbContextFactory and remove direct AppDbContext from DI (#11)
Reviewed-on: #11
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
2026-05-07 16:39:21 +00:00
mjasin 3faecbb639 feat: implement structured logging in KnowledgeCoordinator [MN-01] (#10)
Reviewed-on: #10
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
2026-05-05 18:12:09 +00:00
mjasin ef82effeac docs: add git workflow and MCP integration guidelines to agent persona documentation 2026-05-05 15:13:40 +02:00
mjasin 311eaa8b04 feat: normalize subscription architecture, integrate pgvector, and implement Stripe webhook subscription management. 2026-05-05 15:07:48 +02:00
mjasin e21c24b66d feat: implement multi-tenancy support across knowledge services and normalize TenantId to string type. 2026-05-03 17:52:12 +02:00
mjasin eac0e9057e refactor: migrate agent configurations and skills to the .agent directory and add project documentation 2026-05-03 16:17:43 +02:00
mjasin afdfc31d1a feat: implement KM-RAG methodology artifacts and core architectural standards with supporting query and service updates 2026-05-03 16:12:07 +02:00
mjasin 1f187b5125 feat: implement semantic search, knowledge unit extraction, and visualization components 2026-05-03 15:59:30 +02:00
mjasin 94ecc7a404 feat: implement cross-device reading progress synchronization using SignalR and remove legacy quiz generation services. 2026-05-02 19:55:07 +02:00
mjasin e5611758f1 feat: implement Stripe product configuration and add token-based input validation using Microsoft.ML.Tokenizers 2026-05-02 10:31:28 +02:00
mjasin 0ed89ef5a4 feat: implement AI-driven text streaming and dynamic knowledge graph generation in AiAssistantBubble 2026-05-01 20:34:00 +02:00
mjasin 0cc25bb412 feat: add Microsoft.Extensions.Logging.Abstractions package and global logging namespace import 2026-05-01 20:24:42 +02:00
mjasin 93d8dfde7e refactor: remove Stripe webhook controller, optimize MainLayout rendering, and update DI registration in Program.cs 2026-05-01 20:12:36 +02:00
mjasin 47bffd629f feat: add application preloader, identity roles, and resilient database initialization with automated seeding 2026-05-01 09:07:26 +02:00
258 changed files with 27506 additions and 2181 deletions
+288
View File
@@ -0,0 +1,288 @@
---
name: blazor-expert
description: Comprehensive Blazor development expertise covering Blazor Server, WebAssembly, and Hybrid apps. Use when building Blazor components, implementing state management, handling routing, JavaScript interop, forms and validation, authentication, or optimizing Blazor applications. Includes best practices, architecture patterns, and troubleshooting guidance.
version: 2.0
---
# Blazor Expert - Orchestration Hub
Expert-level guidance for developing applications with Blazor, Microsoft's framework for building interactive web UIs using C# instead of JavaScript.
## Quick Reference: When to Load Which Resource
| Task | Load Resource | Key Topics |
|------|---------------|-----------|
| **Build components, handle lifecycle events** | [components-lifecycle.md](resources/components-lifecycle.md) | Component structure, lifecycle methods, parameters, cascading values, RenderFragment composition |
| **Manage component state, handle events** | [state-management-events.md](resources/state-management-events.md) | Local state, EventCallback, data binding, cascading state, service-based state |
| **Configure routes, navigate between pages** | [routing-navigation.md](resources/routing-navigation.md) | Route parameters, constraints, navigation, NavLink, query strings, layouts |
| **Build forms, validate user input** | [forms-validation.md](resources/forms-validation.md) | EditForm, input components, DataAnnotations validation, custom validators |
| **Setup authentication & authorization** | [authentication-authorization.md](resources/authentication-authorization.md) | Auth setup, AuthorizeView, Authorize attribute, policies, claims |
| **Optimize performance, use JavaScript interop** | [performance-advanced.md](resources/performance-advanced.md) | Rendering optimization, virtualization, JS interop, lazy loading, WASM best practices |
## Orchestration Protocol
### Phase 1: Task Analysis
Identify your primary objective:
- **UI Building** → Load components-lifecycle.md
- **State Handling** → Load state-management-events.md
- **Navigation** → Load routing-navigation.md
- **Data Input** → Load forms-validation.md
- **User Access** → Load authentication-authorization.md
- **Speed/Efficiency** → Load performance-advanced.md
### Phase 2: Resource Loading
Open the recommended resource file(s) and search for your specific need using Ctrl+F. Each resource is organized by topic with working code examples.
### Phase 3: Implementation & Validation
- Follow code patterns from the resource
- Adapt to your specific requirements
- Test in appropriate hosting model (Server/WASM/Hybrid)
- Review troubleshooting section if issues arise
## Blazor Hosting Models Overview
### Blazor Server
- **How**: Runs on server via SignalR
- **Best For**: Line-of-business apps, need full .NET runtime, small download size
- **Trade-offs**: High latency, requires connection, server resource intensive
### Blazor WebAssembly
- **How**: Runs in browser via WebAssembly
- **Best For**: PWAs, offline apps, no server dependency, client-heavy applications
- **Trade-offs**: Large initial download, limited .NET APIs, slower cold start
### Blazor Hybrid
- **How**: Runs in MAUI/WPF/WinForms with Blazor UI
- **Best For**: Cross-platform desktop/mobile apps
- **Trade-offs**: Platform-specific considerations, additional dependencies
**Decision**: Choose based on deployment environment, offline requirements, and server constraints.
## Common Implementation Workflows
### Scenario 1: Build a Data-Entry Component
1. Read [components-lifecycle.md](resources/components-lifecycle.md) - Component structure section
2. Read [state-management-events.md](resources/state-management-events.md) - EventCallback pattern
3. Read [forms-validation.md](resources/forms-validation.md) - EditForm component
4. Combine: Create component with parameters → capture user input → validate → notify parent
### Scenario 2: Implement User Authentication & Protected Pages
1. Read [authentication-authorization.md](resources/authentication-authorization.md) - Setup section
2. Read [routing-navigation.md](resources/routing-navigation.md) - Layouts section
3. Read [authentication-authorization.md](resources/authentication-authorization.md) - AuthorizeView section
4. Combine: Configure auth → create login page → protect routes → check auth in components
### Scenario 3: Build Interactive List with Search/Filter
1. Read [routing-navigation.md](resources/routing-navigation.md) - Query strings section
2. Read [state-management-events.md](resources/state-management-events.md) - Data binding section
3. Read [performance-advanced.md](resources/performance-advanced.md) - Virtualization section
4. Combine: Capture search input → update URL query → fetch filtered data → virtualize if large
### Scenario 4: Optimize Performance of Existing App
1. Read [performance-advanced.md](resources/performance-advanced.md) - All sections
2. Identify bottlenecks:
- Unnecessary renders? → ShouldRender override, @key directive
- Large lists? → Virtualization
- JS latency? → Module isolation pattern
3. Apply targeted optimizations from resource
## Key Blazor Concepts
### Component Architecture
- **Components**: Self-contained UI units with optional logic
- **Parameters**: Inputs to components, enable reusability
- **Cascading Values**: Share state with descendants without explicit parameters
- **Events**: Child-to-parent communication via EventCallback
- **Layouts**: Parent wrapper for consistent page structure
### State Management
- **Local State**: Component-specific fields and properties
- **Cascading Values**: Share state to descendants
- **Services**: Application-wide state via dependency injection
- **Event Binding**: React to user interactions
- **Data Binding**: Two-way synchronization with UI
### Routing & Navigation
- **@page Directive**: Make component routable
- **Route Parameters**: Pass data via URL (`{id:int}`)
- **Navigation**: Programmatic navigation via NavigationManager
- **NavLink**: UI component that highlights active route
- **Layouts**: Wrap pages with common structure
### Forms & Validation
- **EditForm**: Form component with validation support
- **Input Components**: Typed controls (InputText, InputNumber, etc.)
- **Validators**: DataAnnotations attributes or custom logic
- **EventCallback**: Notify parent of form changes
- **Messages**: Display validation errors to user
### Authentication & Authorization
- **Claims & Roles**: Identify users and define access levels
- **Policies**: Fine-grained authorization rules
- **Authorize Attribute**: Protect pages from unauthorized access
- **AuthorizeView**: Conditional rendering based on permissions
- **AuthenticationStateProvider**: Get current user information
### Performance Optimization
- **ShouldRender()**: Prevent unnecessary re-renders
- **@key Directive**: Help diffing algorithm match list items
- **Virtualization**: Render only visible items in large lists
- **JS Interop**: Call JavaScript from C# and vice versa
- **AOT/Trimming**: Reduce WASM download size (production)
## Best Practices Highlights
### Component Design
**Single Responsibility** - Each component has one clear purpose
**Composition** - Use RenderFragments for flexible layouts
**Parameter Clarity** - Use descriptive names, mark required with `[EditorRequired]`
**Proper Disposal** - Implement `IDisposable` to clean up resources
**Event-Based Communication** - Use `EventCallback` for child-to-parent updates
### State Management
**EventCallback Over Action** - Proper async handling
**Immutable Updates** - Create new objects/collections, don't mutate
**Service-Based State** - Use scoped services for shared state
**Unsubscribe from Events** - Prevent memory leaks in Dispose
**InvokeAsync for Background Threads** - Thread-safe state updates
### Routing & Navigation
**Route Constraints** - Use `:int`, `:guid`, etc. to validate formats
**NavLink Component** - Automatic active state highlighting
**forceLoad After Logout** - Clear client-side state
**ReturnUrl Pattern** - Redirect back after login
**Query Strings** - Preserve filters/pagination across navigation
### Forms & Validation
**EditForm + DataAnnotationsValidator** - Built-in validation
**ValidationMessage** - Show field-level errors
**Custom Validators** - Extend for complex rules
**Async Validation** - Check server availability before submit
**Loading State** - Disable submit button while processing
### Authentication & Authorization
**Server Validation** - Never trust client-side checks alone
**Policies Over Roles** - More flexible authorization rules
**Claims for Details** - Store user attributes in claims
**Cascading AuthenticationState** - Available in all components
**Error Boundaries** - Graceful error handling
### Performance
**@key on Lists** - Optimize item matching
**ShouldRender Override** - Prevent unnecessary renders
**Virtualization for Large Lists** - Only render visible items
**JS Module Isolation** - Load and cache JS modules efficiently
**AOT for WASM** - Production deployments
## Common Troubleshooting
### Component Not Re-rendering
- **Cause**: Mutation instead of reassignment
- **Fix**: Create new object/collection: `items = items.Append(item).ToList()`
- **Or**: Call `StateHasChanged()` manually
### Parameter Not Updating
- **Cause**: Parent not re-rendering or same object reference
- **Fix**: Parent must re-render, ensure new reference for objects
- **Debug**: Check OnParametersSet is firing
### JS Interop Errors
- **Cause**: Called before script loaded or wrong function name
- **Fix**: Use `firstRender` check, verify JS file path
- **Pattern**: Use module isolation: `await JS.InvokeAsync("import", "./script.js")`
### Authentication State Not Available
- **Cause**: Cascading parameter not provided or timing issue
- **Fix**: Ensure AuthenticationStateProvider configured
- **Pattern**: Always null-check and use `await AuthStateTask!` in code block
### Large List Performance Issues
- **Cause**: Rendering all items in DOM
- **Fix**: Use Virtualize component for 1000+ items
- **Alternative**: Paginate with buttons/infinite scroll
### Blazor Server Connection Issues
- **Cause**: SignalR connection dropped or configuration issue
- **Fix**: Implement reconnection UI, increase timeout
- **Config**: Adjust `CircuitOptions.DisconnectedCircuitRetentionPeriod`
## Resource Files Summary
### components-lifecycle.md
Complete guide to component structure, lifecycle methods, parameters, cascading values, and composition patterns. Essential for understanding Blazor component fundamentals.
### state-management-events.md
Comprehensive coverage of local and service-based state, event handling with EventCallback, data binding patterns, and component communication. Core for interactive UI building.
### routing-navigation.md
Complete routing reference including route parameters, constraints, programmatic navigation, query strings, and layout management. Essential for multi-page apps.
### forms-validation.md
Full forms API with EditForm component, input controls, DataAnnotations validation, custom validators, and form patterns. Required for data entry scenarios.
### authentication-authorization.md
Complete auth setup for Server and WASM, AuthorizeView, policies, claims-based access control, and login/logout patterns. Necessary for secured applications.
### performance-advanced.md
Performance optimization techniques including ShouldRender, virtualization, JavaScript interop patterns, lazy loading, and WASM best practices. Vital for production apps.
---
## Implementation Approach
When implementing Blazor features:
1. **Identify Your Task** - Match against the decision table above
2. **Load Relevant Resource** - Read the appropriate .md file
3. **Find Code Example** - Search resource for similar implementation
4. **Adapt to Your Context** - Modify for your specific requirements
5. **Test Thoroughly** - Verify in your hosting model
6. **Reference Troubleshooting** - Consult resource if issues arise
## Next Steps
- **New to Blazor?** Start with [components-lifecycle.md](resources/components-lifecycle.md)
- **Building Data App?** Move through: components → state → forms → validation
- **Scaling Existing App?** Focus on [performance-advanced.md](resources/performance-advanced.md)
- **Adding Security?** Follow [authentication-authorization.md](resources/authentication-authorization.md)
---
**Version**: 2.0 - Modular Orchestration Pattern
**Last Updated**: December 4, 2025
**Status**: Production Ready ✅
@@ -0,0 +1,533 @@
# Blazor Authentication & Authorization
## Authentication Setup
### Blazor Server Setup
```csharp
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add authentication
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/logout";
options.AccessDeniedPath = "/unauthorized";
});
builder.Services.AddAuthorizationCore();
// Add Blazor Server
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
var app = builder.Build();
// Add authentication middleware BEFORE MapRazorPages
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
app.MapBlazorHub();
```
### Blazor WebAssembly Setup
```csharp
// Program.cs
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
// Add authentication
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
builder.Services.AddScoped<HttpClient>(sp =>
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
await builder.Build().RunAsync();
// CustomAuthStateProvider
public class CustomAuthStateProvider : AuthenticationStateProvider
{
private readonly HttpClient httpClient;
public CustomAuthStateProvider(HttpClient httpClient)
{
this.httpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
var user = await httpClient.GetJsonAsync<UserInfo>("/api/user");
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Name, user.Name),
new Claim(ClaimTypes.Email, user.Email)
};
var identity = new ClaimsIdentity(claims, "Custom");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
catch
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
}
}
```
## AuthorizeView Component
AuthorizeView displays content conditionally based on authorization status.
### Basic Authorization Check
```html
<AuthorizeView>
<Authorized>
<p>Hello, @context.User.Identity?.Name!</p>
</Authorized>
<NotAuthorized>
<p>Please log in.</p>
</NotAuthorized>
</AuthorizeView>
```
### Authorize by Role
```html
<AuthorizeView Roles="Admin">
<p>This content is only for Admins</p>
</AuthorizeView>
<AuthorizeView Roles="User, Moderator">
<p>User or Moderator content</p>
</AuthorizeView>
```
### Authorize by Policy
```html
<AuthorizeView Policy="ContentEditor">
<p>Only content editors can see this</p>
</AuthorizeView>
```
### Multiple AuthorizeView States
```html
<AuthorizeView>
<Authorized>
@if (context.User.IsInRole("Admin"))
{
<p>Admin dashboard</p>
}
else if (context.User.IsInRole("Editor"))
{
<p>Editor dashboard</p>
}
else
{
<p>User dashboard</p>
}
</Authorized>
<Authorizing>
<p>Checking authorization...</p>
</Authorizing>
<NotAuthorized>
<p>Not authorized</p>
</NotAuthorized>
</AuthorizeView>
```
### Authorize Multiple Resources
```html
<AuthorizeView Context="Auth">
<Authorized>
<div>
<h2>@Auth.User.Identity?.Name</h2>
@if (Auth.User.IsInRole("Admin"))
{
<a href="/admin">Admin Panel</a>
}
@if (Auth.User.HasClaim("department", "engineering"))
{
<a href="/engineering">Engineering</a>
}
</div>
</Authorized>
</AuthorizeView>
```
## Authorize Attribute
Apply `[Authorize]` to pages to require authentication.
### Basic Page Authorization
```csharp
@page "/admin"
@attribute [Authorize]
<h2>Admin Page</h2>
<p>Only authenticated users can see this.</p>
```
### Role-Based Authorization
```csharp
@page "/admin"
@attribute [Authorize(Roles = "Admin")]
<h2>Admin Panel</h2>
<p>Only admins can access this page.</p>
```
### Policy-Based Authorization
```csharp
@page "/dashboard"
@attribute [Authorize(Policy = "RequireAdminRole")]
<h2>Dashboard</h2>
```
### Multiple Requirements
```csharp
@page "/admin"
@attribute [Authorize(Roles = "Admin, Manager")]
@attribute [Authorize(Policy = "ActiveSubscription")]
<h2>Admin Dashboard</h2>
```
## Authorization Policies
Define fine-grained authorization policies.
### Setup Policies
```csharp
// Program.cs
builder.Services.AddAuthorizationCore(options =>
{
options.AddPolicy("RequireAdminRole", policy =>
policy.RequireRole("Admin"));
options.AddPolicy("ActiveSubscription", policy =>
policy.Requirements.Add(new ActiveSubscriptionRequirement()));
options.AddPolicy("ContentEditor", policy =>
policy.RequireClaim("department", "engineering", "content"));
options.AddPolicy("AdultUser", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(18)));
});
// Add custom policy handler
builder.Services.AddSingleton<IAuthorizationHandler, ActiveSubscriptionHandler>();
```
### Custom Policy Handlers
```csharp
public class ActiveSubscriptionRequirement : IAuthorizationRequirement { }
public class ActiveSubscriptionHandler : AuthorizationHandler<ActiveSubscriptionRequirement>
{
private readonly IUserService userService;
public ActiveSubscriptionHandler(IUserService userService)
{
this.userService = userService;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ActiveSubscriptionRequirement requirement)
{
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
context.Fail();
return;
}
var user = await userService.GetUserAsync(userId);
if (user?.SubscriptionActive == true)
{
context.Succeed(requirement);
}
else
{
context.Fail();
}
}
}
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public int MinimumAge { get; set; }
public MinimumAgeRequirement(int minimumAge)
{
MinimumAge = minimumAge;
}
}
```
## Accessing Authentication State
### In Components
```csharp
@page "/user-profile"
@if (authState == null)
{
<p>Loading...</p>
}
else if (authState.User.Identity?.IsAuthenticated == true)
{
<h2>Welcome, @authState.User.Identity?.Name</h2>
}
else
{
<p>Not authenticated</p>
}
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private AuthenticationState? authState;
protected override async Task OnInitializedAsync()
{
authState = await AuthStateTask!;
}
}
```
### Check Claims and Roles
```csharp
@code {
private async Task CheckUserAsync()
{
var authState = await AuthStateTask!;
var user = authState.User;
if (user.Identity?.IsAuthenticated == true)
{
var name = user.Identity.Name;
var email = user.FindFirst(ClaimTypes.Email)?.Value;
var isAdmin = user.IsInRole("Admin");
var department = user.FindFirst("department")?.Value;
}
}
}
```
## Login/Logout Implementation
### Login Page
```csharp
@page "/login"
@layout BlankLayout
<div class="login-form">
<h2>Login</h2>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
<EditForm Model="@model" OnValidSubmit="@HandleLoginAsync">
<DataAnnotationsValidator />
<div class="form-group">
<label>Email:</label>
<InputText @bind-Value="model.Email" class="form-control" />
</div>
<div class="form-group">
<label>Password:</label>
<InputText @bind-Value="model.Password" type="password" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Login</button>
</EditForm>
</div>
@code {
[SupplyParameterFromQuery]
public string? ReturnUrl { get; set; }
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
private LoginModel model = new();
private string? errorMessage;
private async Task HandleLoginAsync()
{
try
{
var result = await AuthService.LoginAsync(model.Email, model.Password);
// Update authentication state
if (AuthStateProvider is CustomAuthStateProvider customAuth)
{
await customAuth.SetUserAsync(result.User);
}
// Redirect to return URL or home
var url = !string.IsNullOrEmpty(ReturnUrl) ? ReturnUrl : "/";
Navigation.NavigateTo(url, forceLoad: true);
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
}
}
public class LoginModel
{
[Required]
[EmailAddress]
public string Email { get; set; } = "";
[Required]
public string Password { get; set; } = "";
}
```
### Logout Endpoint
```csharp
// Pages/Logout.cshtml (in Blazor Server)
@page "/logout"
@using Microsoft.AspNetCore.Identity
@inject SignInManager<IdentityUser> SignInManager
@inject NavigationManager Navigation
@code {
protected override async Task OnInitializedAsync()
{
await SignInManager.SignOutAsync();
Navigation.NavigateTo("/");
}
}
```
## Claims-Based Authorization
Working with claims for fine-grained authorization.
### Add Claims to User
```csharp
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Name, user.Name),
new Claim(ClaimTypes.Email, user.Email),
new Claim("department", "engineering"),
new Claim("level", "senior")
};
var identity = new ClaimsIdentity(claims, "Custom");
var principal = new ClaimsPrincipal(identity);
```
### Check Claims in Component
```csharp
@code {
private async Task CheckDepartmentAsync()
{
var authState = await AuthStateTask!;
var user = authState.User;
var department = user.FindFirst("department")?.Value;
var level = user.FindFirst("level")?.Value;
if (department == "engineering")
{
// Show engineering-specific UI
}
}
}
```
## Best Practices
### Use Cascading AuthenticationState
```csharp
// App.razor - already cascades AuthenticationState by default
<CascadingAuthenticationState>
<Router ... />
</CascadingAuthenticationState>
```
### Always Check firstRender in OnAfterRender
```csharp
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Initialize only once
authState = await AuthStateTask!;
StateHasChanged();
}
}
```
### Use forceLoad for Logout
```csharp
private async Task LogoutAsync()
{
await AuthService.LogoutAsync();
// forceLoad clears client-side state
Navigation.NavigateTo("/", forceLoad: true);
}
```
### Validate on Server
- Never trust client-side authorization
- Always validate authorization on backend API
- Check claims/roles on server methods
### Use ReturnUrl After Login
```csharp
// Redirect back to originally-requested page
Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}");
```
---
**Related Resources:** See [routing-navigation.md](routing-navigation.md) for route-based authorization. See [components-lifecycle.md](components-lifecycle.md) for parameter security.
@@ -0,0 +1,550 @@
# Blazor Components & Component Lifecycle
## Component Structure
Components are the fundamental building blocks of Blazor applications. A Blazor component is a self-contained piece of UI with an optional logic.
### Basic Component Syntax
```csharp
@page "/example"
@using MyApp.Services
@inject IMyService MyService
<h3>@Title</h3>
<div>@ChildContent</div>
<button @onclick="HandleClick">Click me</button>
@code {
[Parameter]
public string Title { get; set; } = "Default";
[Parameter]
public RenderFragment? ChildContent { get; set; }
private void HandleClick()
{
// Handle button click
}
}
```
Key elements:
- **`@page` directive**: Makes component routable (optional for non-page components)
- **`@using`**: Import namespaces
- **`@inject`**: Dependency injection
- **HTML markup**: Regular HTML with Blazor directives
- **`@code` block**: C# logic including lifecycle methods
### Component vs Page
- **Page Component**: Has `@page` directive, routable via URL
- Example: `/Counter` route
- Located in `Pages/` folder (convention)
- **Reusable Component**: No `@page` directive, used by other components
- Example: `<UserCard @bind-User="user" />`
- Located in `Shared/` or domain-specific folder
## Component Lifecycle
### Lifecycle Sequence
Component lifecycle methods execute in this order:
```
1. SetParametersAsync()
2. OnInitialized() or OnInitializedAsync()
3. OnParametersSet() or OnParametersSetAsync()
4. ShouldRender() [decision point - skip if returns false]
5. OnAfterRender() or OnAfterRenderAsync()
```
When parameters change (parent re-renders):
```
SetParametersAsync() [parameters updated]
OnParametersSet() [NOT OnInitialized - that runs once only]
ShouldRender()
OnAfterRender()
```
### Lifecycle Methods Detailed
#### SetParametersAsync()
- **When**: First method called, before initialization
- **Purpose**: Set component parameters
- **Usage**: Rarely overridden, use OnInitialized instead
- **Code Example**:
```csharp
public override async Task SetParametersAsync(ParameterView parameters)
{
// Custom parameter processing if needed
await base.SetParametersAsync(parameters);
}
```
#### OnInitialized / OnInitializedAsync()
- **When**: Once per component lifetime, after parameters set
- **Purpose**: Initialize component state, load data
- **Runs**: Only ONCE, even if parameters change
- **Code Example**:
```csharp
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
data = await Service.LoadDataAsync();
}
```
**Common Uses:**
- Load initial data from API
- Set up subscriptions
- Initialize state based on parameters
#### OnParametersSet / OnParametersSetAsync()
- **When**: After parameters set, runs EVERY time parameters change
- **Purpose**: React to parameter changes
- **Runs**: Every time parent re-renders with different values
- **Code Example**:
```csharp
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
if (UserId != previousUserId)
{
data = await Service.LoadUserDataAsync(UserId);
previousUserId = UserId;
}
}
```
**Common Uses:**
- Update UI based on new parameter values
- Fetch new data when ID parameter changes
- React to cascading parameter changes
#### ShouldRender()
- **When**: Before DOM rendering, decision point
- **Purpose**: Optimize rendering by skipping unnecessary renders
- **Returns**: true (render) or false (skip)
- **Code Example**:
```csharp
protected override bool ShouldRender()
{
// Only render if specific field changed
return hasChanged;
}
```
**Common Optimizations:**
- Skip render if data unchanged
- Prevent re-render from external events
- Implement custom change detection
#### OnAfterRender / OnAfterRenderAsync()
- **When**: After component rendered to DOM
- **Purpose**: Work with DOM, initialize JS libraries, final setup
- **Parameter**: `firstRender` - true only on first render
- **Code Example**:
```csharp
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Initialize JS library only once
await JS.InvokeVoidAsync("initializeChart", elementRef);
}
}
```
**Critical Use Case:**
```csharp
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
// Load JS module
module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./scripts/myScript.js");
// Initialize library
await module.InvokeVoidAsync("setupChart", element);
}
}
```
**Important:** Always use `firstRender` check for one-time initialization. This prevents re-initializing on every parameter change.
## Component Parameters
### Parameter Declaration
```csharp
@code {
// Simple parameter
[Parameter]
public string Title { get; set; } = "Default";
// Required parameter (C# 11+)
[Parameter, EditorRequired]
public int UserId { get; set; }
// Child content
[Parameter]
public RenderFragment? ChildContent { get; set; }
// Event callback
[Parameter]
public EventCallback<string> OnValueChanged { get; set; }
// Cascading parameter
[CascadingParameter]
public ThemeInfo? CurrentTheme { get; set; }
}
```
### Parameter Best Practices
**Use Clear Names:**
```csharp
// ✅ Good - clear intent
[Parameter]
public bool IsVisible { get; set; }
// ❌ Poor - ambiguous
[Parameter]
public bool State { get; set; }
```
**Use Nullable Types for Optional:**
```csharp
// ✅ Good - nullable indicates optional
[Parameter]
public string? OptionalValue { get; set; }
// ✅ Good - default value
[Parameter]
public int MaxItems { get; set; } = 10;
// ❌ Poor - not clear if optional
[Parameter]
public string RequiredValue { get; set; }
```
**Use [EditorRequired] for Required Parameters (C# 11+):**
```csharp
// ✅ Best practice - compiler enforces, IDE warns
[Parameter, EditorRequired]
public string Title { get; set; } = default!;
// Fallback for older C#
[Parameter]
public string Title { get; set; } = default!;
```
**Use EventCallback for Child-to-Parent Communication:**
```csharp
// ✅ Correct - EventCallback for async safety
[Parameter]
public EventCallback<string> OnValueChanged { get; set; }
// ✅ With custom args
[Parameter]
public EventCallback<ValueChangeEventArgs> OnValueChanged { get; set; }
// ❌ Avoid - direct Action, not async-safe
[Parameter]
public Action<string>? OnValueChanged { get; set; }
```
### Parameter Change Detection
To know when a parameter changed:
```csharp
@code {
private int previousUserId;
[Parameter]
public int UserId { get; set; }
protected override async Task OnParametersSetAsync()
{
if (UserId != previousUserId)
{
previousUserId = UserId;
await LoadUserData();
}
}
}
```
Or use a comparison strategy:
```csharp
private object? previousCriteria;
protected override async Task OnParametersSetAsync()
{
var currentCriteria = (SearchId, SearchTerm);
if (!Equals(previousCriteria, currentCriteria))
{
previousCriteria = currentCriteria;
await PerformSearch();
}
}
```
## Cascading Values
Cascading values allow ancestor components to provide data to all descendants without explicit parameter passing.
### Providing Cascading Values
```csharp
<!-- Parent component -->
<CascadingValue Value="@currentUser">
@ChildContent
</CascadingValue>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
private User currentUser = new();
}
```
### Receiving Cascading Values
```csharp
<!-- Child component anywhere in hierarchy -->
@code {
[CascadingParameter]
public User? CurrentUser { get; set; }
protected override void OnInitialized()
{
if (CurrentUser == null)
{
// Handle missing cascading value
}
}
}
```
### Multiple Cascading Values
```csharp
<!-- Provider -->
<CascadingValue Value="@theme">
<CascadingValue Value="@currentUser">
<CascadingValue Value="@permissions">
@ChildContent
</CascadingValue>
</CascadingValue>
</CascadingValue>
<!-- Consumer - multiple parameters -->
@code {
[CascadingParameter]
public Theme? Theme { get; set; }
[CascadingParameter]
public User? CurrentUser { get; set; }
[CascadingParameter]
public Permissions? Permissions { get; set; }
}
```
### Named Cascading Values
For disambiguation when multiple values of same type:
```csharp
<!-- Provider -->
<CascadingValue Value="@themeLight" Name="Light">
<CascadingValue Value="@themeDark" Name="Dark">
@ChildContent
</CascadingValue>
</CascadingValue>
<!-- Consumer -->
@code {
[CascadingParameter(Name = "Light")]
public Theme? LightTheme { get; set; }
[CascadingParameter(Name = "Dark")]
public Theme? DarkTheme { get; set; }
}
```
## RenderFragment for Component Composition
RenderFragment enables flexible component composition.
### Basic RenderFragment
```csharp
<!-- Parent component -->
<div>
<h2>Header</h2>
@ChildContent
<footer>Footer</footer>
</div>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
<!-- Usage -->
<Layout>
<p>This is the main content</p>
</Layout>
```
### Typed RenderFragment with Context
```csharp
<!-- ListComponent.razor -->
@foreach (var item in Items)
{
@ItemTemplate(item)
}
@code {
[Parameter]
public IEnumerable<Item> Items { get; set; } = [];
[Parameter]
public RenderFragment<Item>? ItemTemplate { get; set; }
}
<!-- Usage -->
<ListComponent Items="@items">
<ItemTemplate Context="item">
<div>@item.Name - @item.Price</div>
</ItemTemplate>
</ListComponent>
```
### Multiple Named Content Areas
```csharp
<!-- Card component with multiple slots -->
<div class="card">
<div class="card-header">@Header</div>
<div class="card-body">@Body</div>
<div class="card-footer">@Footer</div>
</div>
@code {
[Parameter]
public RenderFragment? Header { get; set; }
[Parameter]
public RenderFragment? Body { get; set; }
[Parameter]
public RenderFragment? Footer { get; set; }
}
<!-- Usage -->
<Card>
<Header>
<h3>Card Title</h3>
</Header>
<Body>
<p>Card content</p>
</Body>
<Footer>
<button>Action</button>
</Footer>
</Card>
```
## Component Best Practices
### Single Responsibility
- Each component should have one clear purpose
- Avoid god components that do too much
- Example: `UserProfile` component should focus on displaying user info, not handle complex business logic
### Composition Over Inheritance
- Use cascading values for shared state, not deep hierarchies
- Compose components rather than creating base classes
- Example: Create theme provider component instead of theme-aware base class
### Keep Components Simple
- Minimize `@code` block logic
- Move complex logic to services
- Example: Validation logic → ValidationService, not in component
### Proper Disposal
- Implement `IDisposable` or `IAsyncDisposable`
- Unsubscribe from events
- Dispose timers and resources
```csharp
@implements IAsyncDisposable
@inject IJSRuntime JS
private IJSObjectReference? module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./myScript.js");
}
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
if (module is not null)
{
await module.DisposeAsync();
}
}
```
---
**Related Resources:** See [state-management-events.md](state-management-events.md) for event handling and state updates. See [performance-advanced.md](performance-advanced.md) for optimization techniques.
@@ -0,0 +1,589 @@
# Blazor Forms & Validation
## EditForm Component
EditForm provides a complete form handling solution with data binding and validation.
### Basic EditForm
```csharp
@page "/register"
<EditForm Model="@model" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label>Name:</label>
<InputText @bind-Value="model.Name" class="form-control" />
<ValidationMessage For="@(() => model.Name)" />
</div>
<div class="form-group">
<label>Email:</label>
<InputText @bind-Value="model.Email" class="form-control" />
<ValidationMessage For="@(() => model.Email)" />
</div>
<button type="submit" class="btn">Register</button>
</EditForm>
@code {
private RegistrationModel model = new();
private async Task HandleValidSubmit()
{
// Form is valid, process data
await Service.RegisterUserAsync(model);
}
}
public class RegistrationModel
{
[Required]
[StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = "";
[Required]
[EmailAddress]
public string Email { get; set; } = "";
[Required]
[MinLength(8)]
public string Password { get; set; } = "";
}
```
### EditForm Events
```csharp
<EditForm Model="@model"
OnValidSubmit="@OnValidSubmit"
OnInvalidSubmit="@OnInvalidSubmit"
OnSubmit="@OnSubmit">
<!-- Form content -->
</EditForm>
@code {
private async Task OnValidSubmit()
{
// Fires when form is valid and submitted
}
private async Task OnInvalidSubmit()
{
// Fires when form is invalid and submitted
}
private async Task OnSubmit()
{
// Fires for any submit (valid or invalid)
// Useful for custom validation logic
}
}
```
### Form State Control
```csharp
@inject EditFormService FormService
<EditForm Model="@model" @ref="form">
<!-- Form content -->
</EditForm>
<button @onclick="Submit">Submit</button>
<button @onclick="Reset">Reset</button>
<button @onclick="CheckValid">Is Valid?</button>
@code {
private EditForm? form;
private UserModel model = new();
private async Task Submit()
{
// Manually trigger validation and submission
await form!.RequestValidationAsync();
// Check if valid
if (form!.EditContext.IsModified() && form!.EditContext.Validate())
{
// Process form
}
}
private void Reset()
{
// Reset all fields to default
form!.EditContext.ResetEditingItemAsync();
}
private void CheckValid()
{
bool isValid = form!.EditContext.Validate();
Console.WriteLine($"Form valid: {isValid}");
}
}
```
## Input Components
### Text Input
```csharp
<InputText @bind-Value="model.Name" class="form-control" />
<InputTextArea @bind-Value="model.Description" rows="4" />
@code {
private UserModel model = new();
}
```
### Numeric Input
```csharp
<InputNumber @bind-Value="model.Age" class="form-control" />
<InputNumber @bind-Value="model.Price" @bind-Value:format="N2" />
@code {
private int age;
private decimal price;
}
```
**Format specifiers:**
- `N2` - Number with 2 decimal places
- `C` - Currency
- `P` - Percentage
- `D` - Date
- `X` - Hexadecimal
### Date Input
```csharp
<InputDate @bind-Value="model.BirthDate" />
<InputDate @bind-Value="model.StartTime" Type="InputDateType.DateTimeLocal" />
@code {
private DateTime birthDate;
private DateTime startTime;
}
```
**Types:**
- `InputDateType.Date` - Date only (default)
- `InputDateType.DateTimeLocal` - Date and time
- `InputDateType.Month` - Month and year
- `InputDateType.Time` - Time only
### Select/Dropdown
```csharp
<InputSelect @bind-Value="model.Category" class="form-control">
<option value="">Select a category...</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</InputSelect>
<!-- Dynamic options from data -->
<InputSelect @bind-Value="model.CategoryId">
<option value="">Select...</option>
@foreach (var cat in categories)
{
<option value="@cat.Id">@cat.Name</option>
}
</InputSelect>
@code {
private string selectedCategory = "";
private List<Category> categories = [];
}
```
### Checkbox
```csharp
<InputCheckbox @bind-Value="model.AgreeToTerms" />
Accept terms of service?
@code {
private bool agreeToTerms = false;
}
```
### Radio Buttons
```csharp
<InputRadioGroup @bind-Value="model.Preference">
<div>
<InputRadio Value="@("option1")" />
<label>Option 1</label>
</div>
<div>
<InputRadio Value="@("option2")" />
<label>Option 2</label>
</div>
</InputRadioGroup>
@code {
private string preference = "option1";
}
```
### File Upload
```csharp
<InputFile OnChange="@HandleFileSelect" />
@code {
private async Task HandleFileSelect(InputFileChangeEventArgs e)
{
var file = e.File;
using var stream = file.OpenReadStream();
var buffer = new byte[stream.Length];
await stream.ReadAsync(buffer);
// Process file
}
}
```
## Validation
### DataAnnotations Validation
```csharp
public class UserModel
{
[Required(ErrorMessage = "Name is required")]
[StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = "";
[Required]
[EmailAddress(ErrorMessage = "Invalid email format")]
public string Email { get; set; } = "";
[Range(18, 120, ErrorMessage = "Age must be 18-120")]
public int Age { get; set; }
[Url]
public string? Website { get; set; }
[Phone]
public string? PhoneNumber { get; set; }
[CreditCard]
public string? CardNumber { get; set; }
}
```
**Common Validators:**
- `[Required]` - Field must have value
- `[StringLength(max)]` - Max length
- `[StringLength(max, MinimumLength = min)]` - Min and max
- `[EmailAddress]` - Valid email format
- `[Range(min, max)]` - Numeric range
- `[Url]` - Valid URL format
- `[Phone]` - Valid phone format
- `[CreditCard]` - Valid credit card format
- `[RegularExpression(pattern)]` - Regex match
### ValidationSummary
Shows all validation errors for the form:
```csharp
<EditForm Model="@model" OnValidSubmit="@HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<InputText @bind-Value="model.Name" />
<InputText @bind-Value="model.Email" />
<button type="submit">Submit</button>
</EditForm>
```
Displays as:
```
- Name is required
- Email is required
```
### ValidationMessage
Shows validation error for specific field:
```csharp
<InputText @bind-Value="model.Name" />
<ValidationMessage For="@(() => model.Name)" />
<!-- Custom CSS class -->
<ValidationMessage For="@(() => model.Email)" class="text-danger" />
```
### Custom Validation
Implement `IValidatableObject` for complex validation rules:
```csharp
public class UserModel : IValidatableObject
{
public string Email { get; set; } = "";
public string ConfirmEmail { get; set; } = "";
[Range(18, 100)]
public int Age { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// Compare email fields
if (Email != ConfirmEmail)
{
yield return new ValidationResult(
"Email addresses must match",
new[] { nameof(ConfirmEmail) }
);
}
// Custom age validation
if (Age > 0 && Age < 18 && HasRestrictedContent)
{
yield return new ValidationResult(
"Users under 18 cannot access this content",
new[] { nameof(Age) }
);
}
}
public bool HasRestrictedContent { get; set; }
}
```
### Custom Validators
Create reusable custom validators:
```csharp
public class MinimumAgeAttribute : ValidationAttribute
{
private readonly int _minimumAge;
public MinimumAgeAttribute(int minimumAge)
{
_minimumAge = minimumAge;
}
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is DateTime birthDate)
{
var age = DateTime.Today.Year - birthDate.Year;
if (birthDate.Date > DateTime.Today.AddYears(-age)) age--;
if (age < _minimumAge)
{
return new ValidationResult($"Minimum age is {_minimumAge}");
}
}
return ValidationResult.Success;
}
}
// Usage
public class UserModel
{
[MinimumAge(18)]
public DateTime BirthDate { get; set; }
}
```
### Async Validation
```csharp
public class UniqueEmailAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
// Can't use async in ValidationAttribute
// Use EditContext instead (see below)
return ValidationResult.Success;
}
}
// Better approach: Manual validation in component
@code {
private async Task HandleValidSubmit()
{
// Check email availability before submit
bool isUnique = await Service.IsEmailUniqueAsync(model.Email);
if (!isUnique)
{
form!.EditContext.AddValidationMessages(
FieldIdentifier.Create(() => model.Email),
new[] { "Email is already registered" }
);
return;
}
await SaveUserAsync(model);
}
}
```
## Form Patterns
### Loading State
```csharp
@if (isSubmitting)
{
<p>Saving...</p>
}
else
{
<EditForm Model="@model" OnValidSubmit="@SubmitAsync">
<DataAnnotationsValidator />
<ValidationSummary />
<InputText @bind-Value="model.Name" />
<button type="submit" disabled="@isSubmitting">
@(isSubmitting ? "Saving..." : "Submit")
</button>
</EditForm>
}
@code {
private bool isSubmitting;
private async Task SubmitAsync()
{
isSubmitting = true;
try
{
await Service.SaveAsync(model);
}
finally
{
isSubmitting = false;
}
}
}
```
### Error Handling
```csharp
<EditForm Model="@model" OnValidSubmit="@SubmitAsync">
<DataAnnotationsValidator />
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
<ValidationSummary />
<InputText @bind-Value="model.Name" />
<button type="submit">Submit</button>
</EditForm>
@code {
private string? errorMessage;
private async Task SubmitAsync()
{
try
{
errorMessage = null;
await Service.SaveAsync(model);
}
catch (Exception ex)
{
errorMessage = $"Error: {ex.Message}";
}
}
}
```
### Multi-Step Form
```csharp
@page "/wizard"
@if (currentStep == 1)
{
<h2>Step 1: Basic Info</h2>
<InputText @bind-Value="model.Name" />
<button @onclick="NextStep">Next</button>
}
else if (currentStep == 2)
{
<h2>Step 2: Contact Info</h2>
<InputText @bind-Value="model.Email" />
<button @onclick="PreviousStep">Back</button>
<button @onclick="NextStep">Next</button>
}
else if (currentStep == 3)
{
<h2>Step 3: Confirm</h2>
<p>Name: @model.Name</p>
<p>Email: @model.Email</p>
<button @onclick="PreviousStep">Back</button>
<button @onclick="SubmitAsync">Submit</button>
}
@code {
private int currentStep = 1;
private UserModel model = new();
private void NextStep() => currentStep++;
private void PreviousStep() => currentStep--;
private async Task SubmitAsync()
{
await Service.RegisterAsync(model);
}
}
```
### Real-Time Field Validation
```csharp
<input @bind="email" @bind:event="oninput" @onblur="ValidateEmail" />
@if (!string.IsNullOrEmpty(emailError))
{
<span class="error">@emailError</span>
}
@code {
private string email = "";
private string? emailError;
private void ValidateEmail()
{
if (string.IsNullOrEmpty(email))
{
emailError = "Email is required";
}
else if (!email.Contains("@"))
{
emailError = "Invalid email format";
}
else
{
emailError = null;
}
}
}
```
---
**Related Resources:** See [state-management-events.md](state-management-events.md) for data binding patterns. See [authentication-authorization.md](authentication-authorization.md) for role-based form customization.
@@ -0,0 +1,561 @@
# Blazor Performance & Advanced Patterns
## Rendering Optimization
### ShouldRender Override
Control when components re-render to prevent unnecessary rendering cycles.
```csharp
@page "/optimized"
<button @onclick="IncrementCount">Clicked @count times</button>
<ChildComponent Value="@value" />
@code {
private int count = 0;
private string value = "test";
protected override bool ShouldRender()
{
// Only render if value changed, not if count changed
// This component doesn't display count directly
return false; // Skip render
}
private void IncrementCount()
{
count++;
// Component won't re-render, child component won't re-render either
}
}
```
### Tracking Changed Fields
```csharp
@page "/tracker"
<button @onclick="UpdateName">Update Name</button>
<button @onclick="UpdateAge">Update Age</button>
<p>Name: @name</p>
<p>Age: @age</p>
@code {
private string? name;
private int age;
private bool nameChanged = false;
private bool ageChanged = false;
protected override bool ShouldRender()
{
if (!nameChanged && !ageChanged)
{
return false;
}
nameChanged = false;
ageChanged = false;
return true;
}
private void UpdateName()
{
name = "New Name";
nameChanged = true;
}
private void UpdateAge()
{
age = 30;
ageChanged = true;
}
}
```
### Key Directive for List Items
```csharp
@page "/list"
<button @onclick="AddItem">Add Item</button>
@foreach (var item in items)
{
<!-- WITHOUT @key - new ItemComponent created for each item -->
<!-- <ItemComponent Item="@item" />-->
<!-- WITH @key - same ItemComponent reused if item.Id stays in list -->
<ItemComponent @key="item.Id" Item="@item" />
}
@code {
private List<Item> items = [];
private void AddItem()
{
items = items.Prepend(new Item { Id = Guid.NewGuid(), Name = "New" }).ToList();
}
}
public class Item
{
public Guid Id { get; set; }
public string? Name { get; set; }
}
```
**Why @key matters:**
- Helps Blazor's diffing algorithm match old components to new items
- Prevents component state loss during list reordering
- Improves performance with large lists
### IDisposable for Cleanup
```csharp
@implements IAsyncDisposable
@inject IJSRuntime JS
private IJSObjectReference? module;
private Timer? timer;
protected override async Task OnInitializedAsync()
{
timer = new Timer(_ => UpdateAsync(), null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./myScript.js");
}
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
timer?.Dispose();
if (module is not null)
{
await module.DisposeAsync();
}
}
```
## Virtualization
Virtualize large lists to render only visible items.
### Basic Virtualization
```csharp
@page "/large-list"
@using Microsoft.AspNetCore.Components.Web.Virtualization
<Virtualize Items="@largeList" Context="item">
<div class="item">
<p>@item.Id - @item.Name</p>
</div>
</Virtualize>
@code {
private List<Item> largeList = [];
protected override void OnInitialized()
{
// Generate 100,000 items
largeList = Enumerable.Range(1, 100000)
.Select(i => new Item { Id = i, Name = $"Item {i}" })
.ToList();
}
}
```
### Async Virtualization (Infinite Scroll)
```csharp
@page "/infinite-scroll"
@using Microsoft.AspNetCore.Components.Web.Virtualization
<Virtualize ItemsProvider="@LoadItems" Context="item" OverscanCount="5">
<div>@item.Name</div>
</Virtualize>
@code {
private async ValueTask<ItemsProviderResult<Item>> LoadItems(
ItemsProviderRequest request)
{
// Simulate loading from server
var startIndex = request.StartIndex;
var count = request.Count;
var items = await Service.GetItemsAsync(startIndex, count);
// Return items and total count for scrollbar sizing
return new ItemsProviderResult<Item>(items, totalItemCount: 1000000);
}
}
```
**Parameters:**
- `Items` - Static list of items to virtualize
- `ItemsProvider` - Async method to load items on demand
- `OverscanCount` - Extra items to render outside viewport (default 3)
- `ItemSize` - Estimated height for scrollbar calculation
## JavaScript Interop
### Invoke JavaScript from C #
```csharp
@inject IJSRuntime JS
<button @onclick="CallJavaScript">Click me</button>
@code {
private async Task CallJavaScript()
{
// Simple call - no return value
await JS.InvokeVoidAsync("console.log", "Hello from Blazor");
// With return value
var result = await JS.InvokeAsync<string>("myFunction", arg1, arg2);
// Generic call with any return type
var data = await JS.InvokeAsync<Data>("loadData");
}
}
```
### JS Module Isolation (Recommended)
```csharp
// Component.razor
@implements IAsyncDisposable
@inject IJSRuntime JS
<div @ref="element">
<canvas id="chart"></canvas>
</div>
@code {
private ElementReference element;
private IJSObjectReference? module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Import JS module
module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./scripts/chart.js");
// Call exported function
await module.InvokeVoidAsync("initChart", element);
}
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
if (module is not null)
{
await module.DisposeAsync();
}
}
}
/* scripts/chart.js */
export function initChart(element) {
const canvas = element.querySelector('#chart');
// Initialize chart library
}
```
### Invoke C# from JavaScript
```csharp
// Component.razor
@implements IAsyncDisposable
@inject IJSRuntime JS
<button @onclick="SetupInterop">Setup</button>
@code {
private IJSObjectReference? module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./scripts/interop.js");
// Pass C# object reference to JS
var objRef = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("setupInterop", objRef);
}
}
[JSInvokable]
public async Task HandleJSEvent(string data)
{
Console.WriteLine($"JS called C#: {data}");
// Update component state
StateHasChanged();
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
if (module is not null)
{
await module.DisposeAsync();
}
}
}
/* scripts/interop.js */
let dotnetHelper;
export function setupInterop(dotnetRef) {
dotnetHelper = dotnetRef;
// Call C# method from JS
document.addEventListener('click', async (e) => {
await dotnetHelper.invokeMethodAsync('HandleJSEvent', 'User clicked');
});
}
```
### Error Handling in Interop
```csharp
@code {
private async Task SafeInvokeAsync()
{
try
{
await JS.InvokeVoidAsync("riskyFunction");
}
catch (JSException jsEx)
{
Console.WriteLine($"JS error: {jsEx.Message}");
}
catch (OperationCanceledException)
{
Console.WriteLine("JS call was cancelled");
}
}
}
```
## Lazy Loading
Load assemblies and components on demand.
### Lazy-Loaded Component Routes
```csharp
<!-- App.razor -->
<Router AppAssembly="@typeof(App).Assembly"
AdditionalAssemblies="@additionalAssemblies"
OnNavigateAsync="@OnNavigateAsync">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<p>Loading...</p>
</NotFound>
</Router>
@code {
private List<Assembly>? additionalAssemblies;
protected override async Task OnInitializedAsync()
{
additionalAssemblies = new();
}
private async Task OnNavigateAsync(NavigationContext context)
{
// Load admin assembly only when accessing /admin
if (context.Path.StartsWith("admin"))
{
var adminAssembly = await JS.InvokeAsync<byte[]>(
"fetch", "./_framework/admin.wasm");
additionalAssemblies!.Add(Assembly.Load(adminAssembly));
}
}
}
```
## WASM Performance Best Practices
### AOT Compilation
```xml
<!-- .csproj -->
<PropertyGroup>
<RunAOTCompilation>true</RunAOTCompilation>
</PropertyGroup>
```
Benefits:
- No JIT compilation at runtime
- Faster startup time
- ~20% larger download
- Production recommended
### Trimming
```xml
<!-- .csproj -->
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>
```
Benefits:
- Removes unused code
- ~40% smaller download
- May cause runtime errors if reflection-based code removed
- Test thoroughly in Release build
### Compression
```xml
<!-- .csproj -->
<PropertyGroup>
<BlazorWebAssemblyEnableCompression>true</BlazorWebAssemblyEnableCompression>
</PropertyGroup>
```
Server-side (in Program.cs):
```csharp
app.UseResponseCompression();
builder.Services.AddResponseCompression(opts =>
{
opts.Filters.Add(new GzipCompressionProvider());
opts.Filters.Add(new BrotliCompressionProvider());
});
```
### Minimize JavaScript Interop
```csharp
// INEFFICIENT - Many JS calls
for (int i = 0; i < 1000; i++)
{
await JS.InvokeVoidAsync("updateUI", i);
}
// EFFICIENT - Single JS call with batch data
var updates = Enumerable.Range(0, 1000).ToList();
await JS.InvokeVoidAsync("updateUIBatch", updates);
```
## Error Boundaries
Handle component errors gracefully.
```csharp
@page "/error-demo"
<ErrorBoundary>
<ChildContent>
<ChildComponent />
</ChildContent>
<ErrorContent Context="ex">
<div class="alert alert-danger">
<h4>Error</h4>
<p>@ex.Message</p>
<button @onclick="ResetError">Try Again</button>
</div>
</ErrorContent>
</ErrorBoundary>
@code {
private ErrorBoundary? errorBoundary;
private async Task ResetError()
{
await errorBoundary!.RecoverAsync();
}
}
```
## CSS Isolation
Scope CSS to specific components.
```html
<!-- MyComponent.razor -->
<div class="container">
<h1>@Title</h1>
</div>
<!-- MyComponent.razor.css -->
.container {
background-color: blue;
padding: 20px;
}
h1 {
color: white;
font-size: 24px;
}
```
**Benefits:**
- No global namespace pollution
- Component-specific styling
- CSS automatically scoped to component
- Compiled into assembly
## Best Practices Summary
### Performance
- Use `@key` on list items
- Override `ShouldRender()` to prevent unnecessary renders
- Use virtualization for large lists
- Minimize JavaScript interop calls
- Enable AOT compilation and trimming for WASM
### JavaScript Interop
- Use module isolation pattern
- Always dispose JS module references
- Handle JS exceptions properly
- Only call JS in `OnAfterRender` with firstRender check
- Minimize interop calls for performance
### Architecture
- Keep components simple and focused
- Move logic to services
- Use cascading values for shared state
- Implement IDisposable for cleanup
- Validate authorization on server side
### User Experience
- Show loading states during async operations
- Provide error feedback
- Use AuthorizeView for conditional rendering
- Implement error boundaries
- Test on slow connections
---
**Related Resources:** See [components-lifecycle.md](components-lifecycle.md) for component disposal patterns. See [state-management-events.md](state-management-events.md) for state update optimization.
@@ -0,0 +1,492 @@
# Blazor Routing & Navigation
## Route Definition
Routes map URL paths to Blazor components.
### Basic Route Definition
```csharp
@page "/product"
@page "/product/{id}"
<h3>Product: @Id</h3>
@code {
[Parameter]
public string? Id { get; set; }
}
```
**How it works:**
- `@page` directive makes component routable
- Parameter name in URL (`{id}`) must match parameter name in `@code` block
- Multiple `@page` directives supported (same component, multiple routes)
### Route Parameters
```csharp
@page "/product/{id}"
<p>Product: @id</p>
@page "/category/{categoryId}/product/{productId}"
<p>Category: @categoryId, Product: @productId</p>
@code {
[Parameter]
public string? id { get; set; }
[Parameter]
public string? categoryId { get; set; }
[Parameter]
public string? productId { get; set; }
}
```
**Parameter Matching:**
- Blazor matches route segments to parameter names (case-insensitive)
- `{id}` in route matches `Id` parameter
- Extra parameters in URL are ignored
### Route Constraints
Route constraints enforce parameter type and format:
```csharp
@page "/product/{id:int}" <!-- Integer only -->
@page "/order/{orderId:long}" <!-- Long integer -->
@page "/user/{id:guid}" <!-- GUID format -->
@page "/article/{slug:string}" <!-- String (default) -->
@page "/event/{date:datetime}" <!-- DateTime format -->
@page "/price/{amount:decimal}" <!-- Decimal number -->
@page "/flag/{active:bool}" <!-- Boolean -->
@page "/value/{num:double}" <!-- Double/Float -->
@code {
[Parameter]
public int id { get; set; }
[Parameter]
public Guid id { get; set; }
[Parameter]
public bool active { get; set; }
}
```
**Built-in Constraints:**
- `:int` - Integer values
- `:long` - Long integers
- `:guid` - GUID format
- `:bool` - Boolean
- `:datetime` - DateTime format
- `:decimal` - Decimal numbers
- `:double` / `:float` - Floating point
- `:string` - Any string (default)
### Optional Route Parameters
```csharp
@page "/search"
@page "/search/{searchTerm}"
<p>Search term: @(searchTerm ?? "All results")</p>
@code {
[Parameter]
public string? searchTerm { get; set; }
}
```
### Catch-All Routes
```csharp
@page "/{*pageRoute}"
<p>Page not found: @pageRoute</p>
@code {
[Parameter]
public string? pageRoute { get; set; }
}
```
## Navigation
### Programmatic Navigation
```csharp
@inject NavigationManager Navigation
<button @onclick="GoHome">Go Home</button>
<button @onclick="GoToUser">Go to User</button>
@code {
private void GoHome()
{
Navigation.NavigateTo("/");
}
private void GoToUser()
{
Navigation.NavigateTo("/user/123");
}
}
```
### Navigation with Options
```csharp
// Replace browser history entry instead of adding new one
Navigation.NavigateTo("/home", replace: true);
// Force full page reload from server
Navigation.NavigateTo("/refresh", forceLoad: true);
// Combine options
Navigation.NavigateTo("/new-page", replace: true, forceLoad: true);
```
**When to use `forceLoad: true`:**
- After logout to clear client-side state
- Accessing completely different app
- Clearing service worker cache
- Full server-side initialization needed
### NavLink Component
NavLink automatically highlights active route:
```csharp
<NavLink href="/home" Match="NavLinkMatch.All">
<span class="icon">🏠</span> Home
</NavLink>
<NavLink href="/products" Match="NavLinkMatch.Prefix">
<span class="icon">📦</span> Products
</NavLink>
<NavLink href="/about" Match="NavLinkMatch.None">
About
</NavLink>
@code {
// CSS class applied to active NavLink: active
}
```
**Match options:**
- `NavLinkMatch.All` - Exact URL match required
- `NavLinkMatch.Prefix` - URL starts with href (default)
- `NavLinkMatch.None` - Never highlights
**CSS:**
```css
a.active {
color: white;
background-color: blue;
}
```
### Listen to Location Changes
```csharp
@implements IDisposable
@inject NavigationManager Navigation
<p>Current location: @Navigation.Uri</p>
@code {
protected override void OnInitialized()
{
Navigation.LocationChanged += LocationChanged;
}
private void LocationChanged(object? sender, LocationChangedEventArgs e)
{
Console.WriteLine($"New location: {e.Location}");
// React to navigation
StateHasChanged();
}
public void Dispose()
{
Navigation.LocationChanged -= LocationChanged;
}
}
```
## Query Strings
### Reading Query Parameters
```csharp
@page "/search"
@inject NavigationManager Navigation
<p>Search results for: @searchQuery</p>
@code {
private string? searchQuery;
protected override void OnInitialized()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
searchQuery = query["q"];
}
}
```
**Usage:** `/search?q=blazor``searchQuery = "blazor"`
### Building Query Strings
```csharp
private void Search(string term)
{
Navigation.NavigateTo($"/search?q={Uri.EscapeDataString(term)}");
}
// Or use QueryHelpers (in .NET 6+)
var query = new Dictionary<string, string>
{
{ "q", "blazor" },
{ "page", "1" }
};
var url = NavigationManager.GetUriWithQueryParameters("/search", query);
Navigation.NavigateTo(url);
```
### Multiple Query Parameters
```csharp
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
var category = query["category"];
var page = int.TryParse(query["page"], out var p) ? p : 1;
var sort = query["sort"] ?? "name";
```
**Usage:** `/products?category=electronics&page=2&sort=price`
## Router Configuration
The Router component in `App.razor` configures routing:
```csharp
<!-- App.razor -->
<Router AppAssembly="@typeof(App).Assembly"
AdditionalAssemblies="@additionalAssemblies"
OnNavigateAsync="@OnNavigateAsync">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<PageTitle>@pageTitle</PageTitle>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Page not found</p>
</LayoutView>
</NotFound>
</Router>
@code {
private List<Assembly>? additionalAssemblies;
private string pageTitle = "Loading...";
protected override async Task OnInitializedAsync()
{
// Load assemblies dynamically if needed
additionalAssemblies = new List<Assembly>
{
typeof(SomeOtherAssembly).Assembly
};
}
private async Task OnNavigateAsync(NavigationContext context)
{
// Can be used for lazy loading assemblies
// Not commonly needed
}
}
```
## Layouts
Layouts are parent components that wrap pages.
### Define a Layout
```csharp
<!-- Layouts/MainLayout.razor -->
@inherits LayoutComponentBase
<header>@Header</header>
<nav>@Navigation</nav>
<main>@Body</main>
<footer>@Footer</footer>
@code {
[Parameter]
public RenderFragment? Header { get; set; }
[Parameter]
public RenderFragment? Navigation { get; set; }
[Parameter]
public RenderFragment? Body { get; set; }
[Parameter]
public RenderFragment? Footer { get; set; }
}
```
### Apply Layout to Page
```csharp
@page "/products"
@layout MainLayout
<h2>Products</h2>
```
### Apply Layout to Multiple Pages
```csharp
<!-- _Imports.razor -->
@layout MainLayout
```
Add this line to `_Imports.razor` to apply layout to all components in folder and below.
### Nested Layouts
```csharp
<!-- AdminLayout inherits from MainLayout -->
@inherits MainLayout
<aside>Admin sidebar</aside>
@Body
```
## Page Titles
Update page title (browser tab) dynamically:
```csharp
@page "/products/{id}"
@inject NavigationManager Navigation
<PageTitle>@title</PageTitle>
<h1>@title</h1>
@code {
[Parameter]
public string? id { get; set; }
private string? title;
protected override async Task OnParametersSetAsync()
{
title = await LoadProductTitleAsync(id);
}
private async Task<string> LoadProductTitleAsync(string? id)
{
// Load from service
return $"Product {id}";
}
}
```
## Common Routing Patterns
### Master-Detail Pattern
```csharp
@page "/products"
@page "/products/{id}"
<div style="display: grid; grid-template-columns: 1fr 1fr;">
<ProductList OnSelectProduct="@SelectProduct" />
@if (selectedId != null)
{
<ProductDetail Id="@selectedId" />
}
</div>
@code {
[Parameter]
public string? id { get; set; }
private string? selectedId;
protected override void OnParametersSet()
{
selectedId = id;
}
private void SelectProduct(string productId)
{
Navigation.NavigateTo($"/products/{productId}");
}
}
```
### Breadcrumb Navigation
```csharp
@page "/category/{categoryId}/product/{productId}"
<div class="breadcrumb">
<a href="/">Home</a> /
<a href="/category/@categoryId">@categoryName</a> /
<span>@productName</span>
</div>
@code {
[Parameter]
public string? categoryId { get; set; }
[Parameter]
public string? productId { get; set; }
private string? categoryName;
private string? productName;
protected override async Task OnParametersSetAsync()
{
categoryName = await LoadCategoryAsync(categoryId);
productName = await LoadProductAsync(productId);
}
}
```
### Tab-Based Navigation
```csharp
@page "/settings"
<div class="tabs">
<NavLink href="/settings/profile" Match="NavLinkMatch.All">Profile</NavLink>
<NavLink href="/settings/security" Match="NavLinkMatch.All">Security</NavLink>
<NavLink href="/settings/notifications" Match="NavLinkMatch.All">Notifications</NavLink>
</div>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(SettingsLayout)" />
</Found>
</Router>
```
---
**Related Resources:** See [components-lifecycle.md](components-lifecycle.md) for parameter handling. See [authentication-authorization.md](authentication-authorization.md) for route authorization.
@@ -0,0 +1,575 @@
# Blazor State Management & Events
## Component State
State represents the data that a component manages and renders.
### Local Component State
```csharp
@page "/counter"
<p>Count: @count</p>
<button @onclick="Increment">Click me</button>
@code {
private int count = 0;
private void Increment()
{
count++;
// Re-render happens automatically after event handler
}
}
```
**How it works:**
- Blazor detects state change during event handler execution
- Automatically calls `StateHasChanged()` after handler completes
- Component re-renders with new state
### StateHasChanged() for External Updates
When state updates from outside Blazor's event system, call `StateHasChanged()` explicitly:
```csharp
@implements IDisposable
@inject IJSRuntime JS
private string? externalData;
protected override void OnInitialized()
{
// Subscribe to external event
JS.InvokeVoidAsync("subscribeToEvent", DotNetObjectReference.Create(this));
}
[JSInvokable]
public void NotifyUpdate(string data)
{
externalData = data;
// Blazor doesn't know about JS update, must call explicitly
StateHasChanged();
}
public void Dispose()
{
// Clean up external subscriptions
}
```
### Thread-Safe State Updates with InvokeAsync()
When updating state from background threads (timers, async tasks outside event handlers):
```csharp
@implements IDisposable
private Timer? timer;
private int count = 0;
protected override void OnInitialized()
{
// Timer running on background thread
timer = new Timer(_ => UpdateCount(), null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
}
private void UpdateCount()
{
// WRONG - can't update state from background thread directly
// count++;
// CORRECT - use InvokeAsync to marshal to UI thread
InvokeAsync(() =>
{
count++;
StateHasChanged();
});
}
public void Dispose()
{
timer?.Dispose();
}
```
### State Immutability Pattern
For complex state (objects, lists), follow immutability pattern:
```csharp
@code {
private List<Item> items = [];
// WRONG - mutates in place, may not trigger re-render
private void AddItem()
{
items.Add(new Item { Name = "New" });
}
// CORRECT - create new collection
private void AddItem()
{
items = items.Append(new Item { Name = "New" }).ToList();
}
}
```
## Event Handling
### Basic Click Handler
```csharp
<button @onclick="HandleClick">Click me</button>
@code {
private void HandleClick()
{
// Event handler logic
}
}
```
### EventCallback Pattern (Recommended)
EventCallback is the proper way to notify parent components of events:
```csharp
<!-- Child component -->
<button @onclick="OnClick">Click me</button>
@code {
[Parameter]
public EventCallback<string> OnValueChanged { get; set; }
private async Task OnClick()
{
await OnValueChanged.InvokeAsync("New Value");
}
}
<!-- Parent component -->
<ChildComponent OnValueChanged="@HandleValueChanged" />
@code {
private void HandleValueChanged(string value)
{
// Handle value change
}
}
```
### EventCallback with Arguments
```csharp
<!-- Child -->
<button @onclick="NotifyParent">Send Data</button>
@code {
[Parameter]
public EventCallback<CustomArgs> OnDataChanged { get; set; }
private async Task NotifyParent()
{
var args = new CustomArgs { Id = 123, Name = "Test" };
await OnValueChanged.InvokeAsync(args);
}
}
public class CustomArgs
{
public int Id { get; set; }
public string? Name { get; set; }
}
<!-- Parent -->
<ChildComponent OnDataChanged="@(args => HandleData(args.Id, args.Name))" />
@code {
private void HandleData(int id, string? name)
{
// Process data
}
}
```
### Async Event Handlers
Always use async properly with EventCallback:
```csharp
<!-- Good - async handler, proper awaiting -->
<button @onclick="SaveAsync">Save</button>
@code {
private async Task SaveAsync()
{
isLoading = true;
try
{
await Service.SaveDataAsync(data);
successMessage = "Saved!";
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
isLoading = false;
}
}
}
```
### Common Event Handlers
```csharp
<!-- Click -->
<button @onclick="HandleClick">Click</button>
<!-- Double click -->
<div @ondblclick="HandleDoubleClick">Double click</div>
<!-- Focus/Blur -->
<input @onfocus="HandleFocus" @onblur="HandleBlur" />
<!-- Key events -->
<input @onkeydown="HandleKeyDown" @onkeyup="HandleKeyUp" />
<!-- Mouse events -->
<div @onmouseover="HandleMouseOver" @onmouseout="HandleMouseOut" />
<!-- Change -->
<select @onchange="HandleChange">
<option>Option 1</option>
</select>
<!-- Submit -->
<form @onsubmit="HandleSubmit">
<button type="submit">Submit</button>
</form>
```
### preventDefault and stopPropagation
```csharp
<!-- Prevent form submission -->
<form @onsubmit:preventDefault="true" @onsubmit="HandleSubmit">
<input type="text" />
<button type="submit">Submit</button>
</form>
<!-- Stop event propagation -->
<div @onclick="ParentClick">
<button @onclick="ChildClick" @onclick:stopPropagation="true">
Click - won't bubble
</button>
</div>
```
## Data Binding
### Two-Way Binding (@bind)
```csharp
<input @bind="name" />
<p>You entered: @name</p>
@code {
private string name = "";
}
```
**How it works:**
- `@bind` = `@bind-value` + `@bind-value:event="onchange"`
- Sets value property, listens to onchange event
- Automatic two-way synchronization
### Custom Events with @bind
```csharp
<input @bind="value" @bind:event="oninput" />
@code {
private string value = "";
}
```
Events: `onchange` (default), `oninput` (real-time), `onblur`, etc.
### Numeric Binding
```csharp
<input @bind="age" @bind:culture="CultureInfo.InvariantCulture" />
@code {
private int age = 0;
}
```
### DateTime Binding
```csharp
<input type="date" @bind="date" />
<input type="datetime-local" @bind="dateTime" />
@code {
private DateOnly date = DateOnly.FromDateTime(DateTime.Now);
private DateTime dateTime = DateTime.Now;
}
```
### Binding with Format Specifiers
```csharp
<input @bind="price" @bind:format="N2" />
<p>Price: @price.ToString("C")</p>
@code {
private decimal price = 0;
}
```
### Bind Modifiers
```csharp
<!-- @bind:get / @bind:set for custom logic -->
<input @bind="@value"
@bind:get="parsedValue"
@bind:set="@SetValue" />
@code {
private string value = "";
private string parsedValue
{
get => value.ToUpper();
}
private void SetValue(string val)
{
value = val.ToLower();
}
}
```
## Cascading Values with Events
Provide shared state and event callbacks to child components:
```csharp
<!-- Parent - AppState provider -->
<CascadingValue Value="@appState">
@ChildContent
</CascadingValue>
@code {
private AppState appState = new();
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
<!-- AppState service -->
public class AppState
{
private string _username = "";
public event Action? OnChange;
public string Username
{
get => _username;
set
{
if (_username != value)
{
_username = value;
NotifyStateChanged();
}
}
}
public void NotifyStateChanged() => OnChange?.Invoke();
}
<!-- Child component - subscribe to state changes -->
@implements IDisposable
@code {
[CascadingParameter]
public AppState? AppState { get; set; }
protected override void OnInitialized()
{
if (AppState != null)
{
AppState.OnChange += StateHasChanged;
}
}
public void Dispose()
{
if (AppState != null)
{
AppState.OnChange -= StateHasChanged;
}
}
}
```
## Service-Based State Management
For application-wide state, use services:
```csharp
// Program.cs
builder.Services.AddScoped<AppState>();
// AppState service
public class AppState
{
private string _theme = "light";
private User? _currentUser;
public event Func<Task>? OnStateChange;
public string Theme
{
get => _theme;
set
{
if (_theme != value)
{
_theme = value;
NotifyStateChanged();
}
}
}
public User? CurrentUser
{
get => _currentUser;
set
{
if (_currentUser != value)
{
_currentUser = value;
NotifyStateChanged();
}
}
}
private async Task NotifyStateChanged()
{
if (OnStateChange != null)
{
await OnStateChange.Invoke();
}
}
}
// Component using AppState
@inject AppState AppState
@implements IAsyncDisposable
@code {
protected override async Task OnInitializedAsync()
{
AppState.OnStateChange += StateHasChanged;
AppState.CurrentUser = await LoadUserAsync();
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
if (AppState != null)
{
AppState.OnStateChange -= StateHasChanged;
}
}
}
```
## Parent-Child Communication Pattern
**Data flow:** Parents pass data DOWN via parameters, children notify UP via events.
```csharp
<!-- Parent -->
@page "/parent"
<h2>Parent: @selectedId</h2>
<Child SelectedId="@selectedId"
OnIdChanged="@HandleIdChanged" />
@code {
private int selectedId = 0;
private async Task HandleIdChanged(int newId)
{
selectedId = newId;
}
}
<!-- Child -->
<select @onchange="OnSelectionChanged">
@foreach (var item in Items)
{
<option value="@item.Id">@item.Name</option>
}
</select>
@code {
[Parameter]
public int SelectedId { get; set; }
[Parameter]
public EventCallback<int> OnIdChanged { get; set; }
private List<Item> Items { get; set; } = [];
private async Task OnSelectionChanged(ChangeEventArgs args)
{
var newId = int.Parse(args.Value?.ToString() ?? "0");
await OnIdChanged.InvokeAsync(newId);
}
}
```
## Best Practices
### Always Use EventCallback
-`[Parameter] public EventCallback OnEvent { get; set; }`
-`[Parameter] public Action? OnEvent { get; set; }`
EventCallback handles async properly and integrates better with Blazor's rendering pipeline.
### Keep Event Handlers Focused
- Do one thing per handler
- Move complex logic to services
- Keep components as thin view layers
### Unsubscribe from Events
Always clean up subscriptions to prevent memory leaks:
```csharp
@implements IDisposable
protected override void OnInitialized()
{
Service.OnChange += HandleChange;
}
public void Dispose()
{
Service.OnChange -= HandleChange;
}
```
### Use Immutable Updates
- Create new objects/collections for state updates
- Don't mutate objects in place
- Helps with change detection and debugging
---
**Related Resources:** See [components-lifecycle.md](components-lifecycle.md) for component parameters and cascading values. See [forms-validation.md](forms-validation.md) for form event handling.
@@ -24,6 +24,10 @@ description: Standards for cross-platform compatibility (Web & MAUI Hybrid)
- Use `IPlatformService.GetDeviceContext()` to determine `DeviceType` (Phone, Tablet, Desktop). - Use `IPlatformService.GetDeviceContext()` to determine `DeviceType` (Phone, Tablet, Desktop).
- Adapt UI layout dynamically based on the context (e.g., sidebars on Tablet/Desktop, bottom navigation on Phone). - Adapt UI layout dynamically based on the context (e.g., sidebars on Tablet/Desktop, bottom navigation on Phone).
- **Real-time & Events (SignalR / UI):**
- **Debouncing**: Implement trailing-edge debouncing using `CancellationTokenSource` and `Task.Delay` for high-frequency UI events (like scrolling). Do not just drop events inside a time window, as the final state might be lost.
- **Dependency Isolation**: Blazor WebAssembly (`Web.Client`) cannot reference projects that require `Microsoft.AspNetCore.App` (like SignalR Hubs). Keep SignalR abstractions in `UI.Shared` and the Hub implementation strictly on the server (`Infrastructure` or `Web.New`).
- **Dependency Injection:** - **Dependency Injection:**
- Register implementations in `MauiProgram.cs` for mobile and `Program.cs` for web. - Register implementations in `MauiProgram.cs` for mobile and `Program.cs` for web.
- Components in `NexusReader.UI.Shared` must only depend on the interfaces. - Components in `NexusReader.UI.Shared` must only depend on the interfaces.
+29
View File
@@ -0,0 +1,29 @@
---
name: km-rag-methodology
description: Expertise in implementing Knowledge-Map RAG (KM-RAG), focusing on structured Knowledge Units, Graph relationships, and multi-stage retrieval in .NET.
tags: [RAG, KnowledgeMap, GraphRAG, AI, .NET, CleanArchitecture]
version: 1.0.0
---
# KM-RAG Methodology
This skill provides a comprehensive framework for transitioning from basic chunk-based RAG to a structured **Knowledge-Map RAG (KM-RAG)** approach.
## Core Concepts
- **Knowledge Units (KU)**: Granular pieces of information with stable IDs and types (Section, Table, Definition, Rule).
- **Knowledge Map (Graph)**: Explicit links between units (`Next`, `Defines`, `Contains`) enabling contextual expansion.
- **Multi-Stage Retrieval**: A pipeline starting with semantic candidate generation followed by graph expansion and optional reranking.
- **Provenance & Governance**: Full traceability of AI answers back to their source units.
## Key Artifacts
- [Core Concepts](artifacts/core_concepts.md): Deep dive into the methodology.
- [Implementation Patterns (.NET)](artifacts/implementation_patterns.md): C# code for units, links, and retrieval.
- [Quality Checklist](artifacts/evaluation_checklist.md): Metrics and safety procedures.
- [Deep Research Report](artifacts/deep-research-report-rag.md): Original research on the KM-RAG approach.
## Usage
Use this skill when:
- Designing or refactoring RAG systems for high precision.
- Implementing multi-tenant knowledge bases.
- Enhancing AI answers with structural context from a graph.
- Building evaluation pipelines for hallucination detection.
@@ -0,0 +1,28 @@
# Core Concepts of KM-RAG (Knowledge-Map RAG)
Knowledge-Map RAG (KM-RAG) shifts the paradigm from "mechanical chunking" to "structured knowledge engineering".
## 1. From Chunks to Knowledge Units (KU)
Instead of random character-based splits, knowledge is partitioned into **Knowledge Units** that preserve structural meaning:
- **Unit Types**: `Section`, `Table`, `Definition`, `ProcedureStep`, `PolicyRule`.
- **Properties**: Stable ID, Version, Canonical Text, Rendered Context, Provenance (source, page, path).
## 2. The Knowledge Map (Graph)
Relationships between Knowledge Units are explicitly modeled to enhance retrieval and context assembly:
- `HAS_UNIT`: Document contains Unit.
- `NEXT` / `PREVIOUS`: Sequential flow between units.
- `DEFINES`: Unit defines a specific entity or term.
- `REFERENCES`: Unit refers to another unit.
- `EXCEPTION_OF`: Unit describes an exception to a rule in another unit.
## 3. Retrieval Strategy: "Plan over Similarity"
Retrieval is not just `top-k` similarity but a multi-stage process:
1. **Candidate Generation**: Hybrid search (Vector + Keyword) to find potential matches.
2. **Graph Expansion**: Pulling related units (e.g., "Get the section this table belongs to" or "Get the definition of term X used here").
3. **Reranking**: Using a Cross-Encoder to precisely score the expanded candidates.
4. **Context Assembly**: Building a grounded context with explicit citations.
## 4. Governance and Provenance
- **Audit Trail**: Every answer must be traceable back to specific Knowledge Units with valid provenance.
- **Permission-Aware**: Retrieval filters must enforce ACLs at the unit/graph level before the LLM sees the data.
- **Continuous Evaluation**: Monitoring "Faithfulness" (groundedness) and "Answer Relevance" using tools like RAGAS or TruLens.
@@ -0,0 +1,588 @@
# Mapa Wiedzy i kontrola w RAG: jak wdrożyć „nowe podejście” w sposób inżynieryjny
## Executive summary
Autor posta (entity["people","Vladimir Alekseichenko","dataworkshop ceo"], entity["organization","DataWorkshop","ml/ai training poland"]) kontrastuje „klasyczny” RAG oparty o mechaniczne chunkowanie i wektoryzację z podejściem, w którym buduje się **Mapę Wiedzy**: „graf z metadanymi, powiązaniami i odniesieniami do źródeł” (w kontekście praktyki na danych z entity["organization","Giełda Papierów Wartościowych w Warszawie","warsaw stock exchange"]). citeturn2view0turn2view1
W tym raporcie formalizuję tę ideę jako **KnowledgeMap RAG (KMRAG)**: RAG, w którym warstwa „R” nie jest tylko wyszukiwaniem semantycznym po losowych fragmentach, ale **kontrolowanym wyborem jednostek wiedzy** (sekcja, tabela, rekord, definicja, reguła) powiązanych grafowo, z pełną **proweniencją (skąd to jest), politykami dostępu, wersjonowaniem i testowalnością**. To jest spójne z tezą autora, że „R w RAG” to przede wszystkim **ryzyko**: jeśli retrieval jest błędny, model będzie „pewnie” odpowiadał na podstawie złego kontekstu. citeturn2view0turn6view0
Ponieważ nie podałeś ograniczeń (skala, budżet, SLA/latencja), przyjmuję **brak specyficznych constraintów** i podaję warianty: od małych wdrożeń (Postgres/pgvector) po architektury wielotenancy (Qdrant/Pinecone/Weaviate) oraz hybrydy graf + wektory. citeturn12search2turn14search1turn14search16turn14search0turn14search2
Najważniejsze rekomendacje wdrożeniowe:
Po pierwsze, zastąp „losowe chunki” **jednostkami sensu**: segmentacją strukturalną (nagłówki/sekcje/tabele) i/lub semantyczną, z metadanymi i relacjami (poprzedni/następny, należy do sekcji, cytuje, definiuje). citeturn6view0turn11search1turn11search29
Po drugie, zbuduj **Mapę Wiedzy jako graf** (property graph) + indeksy (wektorowy i leksykalny/hybrydowy). Praktycznie: graf przechowuje relacje i proweniencję, a wektory dają tani „candidate generation”; dopiero potem używasz grafu do „dociągnięcia” brakujących kontekstów i do audytu. To jest zgodne z rodziną podejść GraphRAG (np. publikacja entity["company","Microsoft","tech company"] o GraphRAG: graf encji + „community summaries” dla lepszych odpowiedzi na pytania globalne). citeturn0search1turn3search4turn3search20
Po trzecie, „kontrola zamiast nadziei” oznacza: (a) **mierniki retrieval i generation**, (b) automatyczne testy regresji i audyt ścieżki źródeł, (c) monitoring i alerty driftu oraz incydentów bezpieczeństwa (prompt injection, data leakage). W praktyce: RAGAS/TruLens + OWASP LLM Top 10 jako checklisty, plus logowanie „trace” (kontekst → odpowiedź → cytowania). citeturn4search1turn4search2turn4search6turn4search13turn4search7
## Definicja podejścia „Mapa Wiedzy zamiast losowych chunków”
W poście autor opisuje Mapę Wiedzy jako artefakt, który budujesz **w 3 dni**: „graf z metadanymi, powiązaniami i odniesieniami do źródeł” (wspomina też kontekst narzędziowy: repozytorium na entity["company","GitHub","code hosting platform"] i notatki w entity["company","Obsidian","note-taking app company"]). citeturn2view1
Jednocześnie w dłuższym materiale autor rozwija intuicję, dlaczego „chunking + vector DB” bywa drogą donikąd: mechaniczne cięcie rozrywa jednostki sensu (akapit, tabela), a model językowy zwykle **nie weryfikuje kontekstu** odpowiada w oparciu o to, co mu dostarczysz, nawet jeśli kontekst jest sprzeczny (stąd losowość i halucynacje). citeturn6view0turn7view1
### Precyzyjna definicja operacyjna (KMRAG)
**KnowledgeMap RAG (KMRAG)** to architektura RAG, w której warstwa „R” jest realizowana przez:
**Reprezentację wiedzy**: dokumenty są przekształcane do zbioru **jednostek wiedzy** (Knowledge Units) o stabilnej proweniencji (ID, wersja, lokalizacja w źródle) i spójnej semantyce (sekcja definicji, tabela, rozdział, procedura), a nie losowych wycinków znaków. citeturn6view0turn11search9turn16search0
**Mapę (graf) zależności**: jednostki są węzłami grafu (np. DOCUMENT → SECTION → UNIT; ENTITY ↔ UNIT; UNIT ↔ UNIT przez „refers_to/next/derives_from”), a krawędzie niosą informację ułatwiającą retrieval i audyt (np. „to jest definicja terminu X”, „to jest wyjątek od reguły”). citeturn2view1turn10search3turn3search4
**Polityki retrieval**: zapytanie jest mapowane na intencję i encje, a retrieval wykonuje plan: generuje kandydatów (wektory/keyword/hybrid), następnie rozszerza kontekst grafowo (np. sekcja nadrzędna, definicje encji, powiązane tabele), na końcu dokonuje selekcji (rerank/pruning) i buduje kontekst z cytowaniami. citeturn12search3turn12search11turn10search6turn10search31
**Kontrolę i audytowalność**: system jest projektowany tak, aby można było odpowiedzieć na pytania: „Dlaczego ten fragment?”, „Czy użytkownik miał uprawnienia?”, „Jaka wersja źródła?”, „Czy odpowiedź jest ugruntowana (grounded) w kontekście?”. Autor wprost wiąże „mapę wiedzy” z uszczelnianiem rozwiązań, wymaganiami prawnymi/bezpieczeństwa oraz audytowalnością. citeturn7view1turn14search2
### Dlaczego „losowe chunki” są słabą abstrakcją inżynieryjną
Mechaniczne chunkowanie jest często liczone w znakach/tokenach; nawet z overlapem rozrywa strukturę i wymusza „magiczne” heurystyki (większy chunk_size, więcej chunków w kontekście), które łatwo psują wcześniej działające przypadki i utrudniają stabilną ewaluację. citeturn6view0
Z perspektywy governance kluczowy problem jest też bezpieczeństwo: w jednym dokumencie mogą być fragmenty o różnych poziomach dostępu, więc „wrzucanie wszystkiego do jednego kontekstu” łamie zasady separacji i komplikuje zgodność (ten motyw pojawia się u autora wprost). citeturn7view1turn14search2
## Architektura referencyjna i komponenty
Poniżej przedstawiam architekturę komponentową KMRAG, obejmującą: ingestion, mapę wiedzy, strategie segmentacji, embeddingi i wektory, retrievery i rerankery, prompt engineering i grounding, oraz kontrolę halucynacji i ewaluację.
### Diagram architektury
```mermaid
flowchart LR
subgraph Ingestion
A[Źródła: PDF/HTML/DOCX/DB] --> B[Parsing + normalizacja]
B --> C[Jednostki wiedzy: sekcje, tabele, rekordy]
C --> D[Metadane: źródło, wersja, ACL, lokalizacja]
C --> E[Ekstrakcja encji/relacji]
E --> G[(Graf / Mapa Wiedzy)]
C --> F[Embedding + indeks]
F --> V[(Vector DB)]
end
subgraph QueryTime
Q[Zapytanie użytkownika] --> R[Routing/intencja/encje]
R --> V1[Candidate gen: vector/keyword/hybrid]
V1 --> V
V --> K[Top-K kandydatów]
K --> G1[Graph expansion\n(definicje, zależności, sekcje)]
G1 --> G
G --> S[Context assembly + dedup + cytowania]
S --> L[LLM generacja\n(z zasadą "answer from sources")]
L --> O[Odpowiedź + cytowania + confidence]
end
subgraph Control
O --> M[Logi/trace]
M --> EV[Ewaluacja offline/online]
M --> MON[Monitoring KPI + alerty]
end
```
Model ten jest kompatybilny zarówno z „klasycznym RAG” w sensie pracy na wektorach (RAG w ujęciu Lewis et al. zakłada połączenie pamięci parametrycznej i nieparametrycznej poprzez retrieval z indeksu wektorowego), jak i z odmianami grafowymi (GraphRAG: budowa grafu encji i „community summaries” jako warstwa indeksu). citeturn0search2turn0search5turn0search1turn3search4
image_group{"layout":"carousel","aspect_ratio":"16:9","query":["GraphRAG architecture diagram","knowledge graph retrieval augmented generation diagram","vector database similarity search diagram","Neo4j graph visualization example"],"num_per_query":1}
### Ingestion: parsowanie, normalizacja i jednostki wiedzy
W KMRAG ingestion nie kończy się na „wyciągnij tekst z PDF”. Kluczowe jest zachowanie/rekonstrukcja struktury: tytuły, listy, tabele, numer stron, sekcje. Biblioteka entity["company","Unstructured","document processing company"] wprost opisuje „partitioning” jako ekstrakcję ustrukturyzowanych elementów (Title/NarrativeText/ListItem itd.), aby móc decydować, co zachować. citeturn16search0turn16search8turn16search4
Jeśli pracujesz na bardzo różnych formatach lub potrzebujesz także metadanych i obsługi np. zaszyfrowanych PDF, narzędzia z ekosystemu entity["organization","Apache Software Foundation","open source foundation"] (Apache Tika) podkreślają możliwość parsowania PDF, w tym obsługi dokumentów szyfrowanych przy podaniu hasła. citeturn16search1turn16search30
Wniosek projektowy: „Jednostka wiedzy” w KMRAG to obiekt typu np.:
- `unit_type`: `section`, `definition`, `table`, `row`, `procedure_step`, `policy_rule`
- `canonical_text` (tekst do embeddingu i rerankingu)
- `rendered_context` (tekst/fragment do wklejenia do prompta)
- `provenance`: `source_id`, `page`, `section_path`, `span_offsets`
- `governance`: `acl_tags`, `pii_class`, `retention_class`
- `links`: `prev/next`, `references`, `same_topic`
Taki model danych bezpośrednio adresuje problem autora: model nie „weźmie odpowiedzialności” za konfliktujący kontekst, więc to system ma pilnować jakości kontekstu i jego zaufania. citeturn6view0turn7view1
### Strategie segmentacji: od „chunków” do „węzłów” (Nodes)
Jeżeli musisz działać na tekście, i tak będziesz coś „dzielił” różnica polega na tym, czy są to losowe fragmenty znaków, czy **węzły semantyczne**.
- W ekosystemie entity["company","LangChain","llm app framework company"] często proponuje się `RecursiveCharacterTextSplitter` jako „solidny default” dla wielu przypadków, ale to nadal jest heurystyka bazująca na znakach i separatorach. citeturn11search8turn11search0
- entity["company","LlamaIndex","llm data framework company"] oferuje semantyczne parsowanie węzłów: `SemanticSplitterNodeParser` dzieli tekst na grupy zdań powiązane semantycznie (z użyciem embeddingów), a dokumentacja podkreśla, że to alternatywa dla stałego rozmiaru chunków. citeturn11search1turn11search9turn11search29
KMRAG traktuje segmentację jako element modelowania danych: węzły mają typ, hierarchię i relacje.
### Embeddingi i Vector DB: candidate generation + filtrowanie po metadanych
Embeddingi są nadal bardzo użyteczne, ale w KMRAG pełnią rolę „szybkiego generatora kandydatów”, a nie „wyroczni”.
Otwartoźródłowo, entity["company","Hugging Face","ml model hub company"] utrzymuje Sentence Transformers, które dostarcza zarówno modele embeddingowe (bi-encoders), jak i rerankery (cross-encoders). citeturn12search38turn12search3
Warstwa metadanych jest w KMRAG krytyczna: np. do ograniczania domeny, wersji dokumentu, języka, daty wejścia w życie, uprawnień.
- entity["company","Qdrant","vector database company"] opisuje payload/metadata i filtrowanie oraz zaleca indeksowanie pól payload dla efektywności filtrowania. citeturn11search2turn11search6turn11search37
- entity["company","Pinecone","vector database company"] opisuje filtrowanie po metadanych oraz pokazuje wzorzec multitenancy przez namespaces. citeturn11search7turn14search16turn14search12
- entity["company","Weaviate","vector database company"] opisuje hybrydę BM25F + wektory (fuzja wyników i wagi są konfigurowalne) oraz posiada natywną wielodzierżawność (tenant per request). citeturn12search0turn14search0
- entity["company","Milvus","vector database project"] dokumentuje hybrydę sparse+dense i wskazuje scenariusze, w których połączenie poprawia wyniki (semantyka + dopasowanie słów kluczowych). citeturn12search1turn12search5
W KMRAG niemal zawsze warto rozważyć hybrid retrieval (dense + sparse), bo ogranicza „semantic drift” i poprawia precyzję przy terminach domenowych (np. numery, nazwy własne). Jest to wspólny wątek w dokumentacji Weaviate i Pinecone, opisującej fuzję wyników i podejścia do hybrydy. citeturn12search0turn11search3turn11search19
### Retrievery, rerankery i kontrola halucynacji
KMRAG rozdziela retrieval na etapy:
**Candidate generation (tani):** dense retriever (np. dual-encoder) i/lub sparse (BM25). Klasyczna praca o dense retrieval (DPR) pokazuje dual-encoder jako praktyczny mechanizm retrieval i porównuje go do BM25 w QA. citeturn8search0turn8search4
**Reranking (droższy):** cross-encoder reranker znacząco poprawia ranking, ale jest kosztowny, bo ocenia pary (query, doc) wspólnie w modelu. Sentence Transformers opisuje retrieve&rerank pipeline oraz rolę CrossEncodera. citeturn12search11turn12search19
**Graph expansion (precyzja i kompletność):** graf dostarcza „brakujących mostów” (definicje, zależności, wyjątki, kontekst sekcji) oraz daje audyt to jest sedno „Mapy Wiedzy”. W wariantach GraphRAG (Microsoft) graf jest budowany z encji i relacji, a następnie grupowany w społeczności i streszczany, co poprawia odpowiedzi na pytania „globalne” (np. „jakie są główne tematy w korpusie?”), gdzie naiwny RAG zawodzi. citeturn0search1turn0search13turn3search4turn3search20
**Halucynacje i „kontrola”:** literatura proponuje pętle weryfikacji (np. ChainofVerification: draft → pytania weryfikacyjne → niezależne odpowiedzi → final) i mechanizmy samorefleksji (SelfRAG) oraz korekty retrieval (CRAG). Są to techniki „kontroli” na poziomie architektury, a nie tylko promptu. citeturn8search3turn9search1turn9search2
## Opcje projektowe i tradeoffy
### Porównanie: klasyczny RAG vs KMRAG
| Wymiar | Klasyczny „chunk + vector DB” | KMRAG (Mapa Wiedzy) | Konsekwencja praktyczna |
|---|---|---|---|
| Jednostka indeksowania | fragment znaków/tokenów | jednostka sensu: sekcja/tabela/rekord + typ | mniej „urwanych” kontekstów, mniej przypadkowości |
| Reprezentacja | embedding + (czasem) metadata | embedding + metadata + graf relacji + proweniencja | lepsza ścieżka audytu i „dlaczego to” |
| Retrieval | topk similarity | plan retrieval: hybrid + graf expansion + rerank | wyższa precyzja i odporność na trudne pytania |
| Zmiany w danych | częsty reindex, ryzyko regresji | wersjonowanie, testy regresji per typ jednostki | stabilniejsze wdrożenia i migracje |
| Bezpieczeństwo/ACL | łatwo mieszać fragmenty o różnych uprawnieniach | ACL na poziomie jednostki i ścieżki grafu | mniejsze ryzyko wycieku kontekstu |
| Debuggowanie | „dlaczego takie chunki?” | „jaki węzeł, z jakiego źródła, jaka relacja?” | szybsze RCA i audyt |
Uzasadnienie co do problemów chunkingu i „model ufa kontekstowi” pochodzi z materiału autora; definicja Mapy Wiedzy jako grafu z metadanymi i odniesieniami jest wprost w poście. citeturn6view0turn2view1turn7view1
### Wybory technologiczne: wektory, graf, hybryda
Poniżej pokazuję typowe opcje i kompromisy (bez narzuconych constraintów dobór zależy od QPS, wolumenu i wymagań bezpieczeństwa).
**Vector store**
- Qdrant: mocne filtrowanie payload + mechanizmy multitenancy (w tym „tiered multitenancy”). citeturn11search6turn14search1turn14search18
- Pinecone: proste multitenancy przez namespaces; dobrze opisane podejścia do hybrid search (single hybrid index vs osobne indeksy, z plusami i minusami). citeturn14search16turn11search3
- Weaviate: wbudowany hybrid BM25F + wektor, oraz multitenancy z tenantem w operacjach. citeturn12search0turn14search0
- Milvus: rozbudowane podejścia do sparse+dense i multivector, z dokumentacją dla hybrydy. citeturn12search1turn12search5turn12search33
- pgvector: dobre, gdy chcesz „mniej systemów” i akceptujesz kompromisy wydajności; repo dokumentuje różnice IVFFlat vs HNSW (build time/memory vs speedrecall). citeturn12search2turn12search14
- Elasticsearch: istotny, gdy potrzebujesz „enterprise security” (RBAC, field/documentlevel security) i hybrydowego wyszukiwania w jednej platformie. citeturn14search2turn14search15
**Graph / Knowledge Map store**
- Neo4j: bogate wzorce GraphRAG (graph traversal, fulltext, vector, text2cypher). Neo4j publikuje GraphRAG field guide i pakiet GraphRAG dla Pythona. citeturn10search18turn10search14turn10search31turn10search2
- Microsoft GraphRAG: gotowy pipeline budowy grafowego indeksu (encje → społeczności → streszczenia), opensource na GitHubie + dokumentacja „Getting started”. citeturn3search0turn3search31turn3search20turn0search1
- LlamaIndex KnowledgeGraphIndex: praktyczna automatyzacja budowy grafu z tekstu i query po encjach. citeturn10search3turn10search11
**Kompromisy**
- Skalowalność: graf może zmniejszać liczbę „strzałów” w LLM (np. przez prestreszczenia społeczności w GraphRAG) kosztem cięższego ingestion i większej złożoności danych. citeturn0search1turn3search4
- Latencja: rerankery crossencoder podnoszą jakość, ale zwiększają czas (N par do oceny); dlatego standardem jest retrieval → rerank topN, nie rerank całego korpusu. citeturn12search11turn12search19
- Koszt: hybryda i graf często zwiększają koszt ingest (LLM do ekstrakcji encji/relacji), ale zmniejszają koszt „ratowania” jakości w runtime przez kolejne heurystyki. To jest w duchu argumentu autora o „dokładaniu miniklocków” versus poprawa fundamentu. citeturn6view0turn7view1
- Maintainability: mniej „magicznych” parametrów chunk_size; więcej jawnych typów jednostek i testów per typ. citeturn7view1turn13search3
- Security/data governance: najlepiej wspierać **permissionaware retrieval** już w retrieverze (prefilter), bo wtedy model nie ma czego „wyciec”. Dokumentacja Elastic i wektor DB pokazuje mechanizmy RBAC/DLS, namespaces/tenants i filtrowanie po metadanych. citeturn14search2turn14search16turn14search0turn11search6
## Migracja z klasycznego RAG do KMRAG
Migracja jest łatwiejsza, jeśli potraktujesz ją jak refactoring warstwy danych i retrieval, a nie „przepisanie wszystkiego od zera”.
### Ścieżka migracji krok po kroku
**Krok pierwszy: ustal bazową prawdę (baseline) i testy.**
Bez ewaluacji będziesz „liczyć na cud” wprost przeciwieństwo postulatu „kontrola zamiast nadziei”. Zacznij od małego zestawu pytań i oczekiwań (golden set) oraz logowania kontekstu i odpowiedzi. W praktyce możesz użyć RAGAS (metryki retrieval i faithfulness bez konieczności pełnych anotacji) oraz TruLens (RAG triad: context relevance, groundedness, answer relevance). citeturn4search1turn4search2turn4search6
**Krok drugi: dołóż metadane i proweniencję zanim dołożysz graf.**
W klasycznym RAG często brakuje stabilnych ID i lokalizacji w źródle; tymczasem autor wiąże mapę wiedzy z odniesieniami do źródeł. Minimalny zestaw to: `source_id`, `version`, `page/section`, `timestamp`, `acl_tags`. Mechanizmy filtrowania po metadanych są standardem m.in. w Pinecone i Qdrant. citeturn2view1turn11search7turn11search6
**Krok trzeci: zamień chunki na węzły o typach i relacjach.**
Zamiast „1000 znaków”, twórz: `SectionNode`, `TableNode`, `DefinitionNode`, `PolicyNode`. Jeśli nie możesz od razu, przejdź etapowo przez semantyczne node parsers (LlamaIndex) lub segmentację po strukturze dokumentu (partitioning). citeturn11search9turn16search0turn11search1
**Krok czwarty: zbuduj Mapę Wiedzy (graf) i zacznij od najtańszego użycia w runtime.**
Nie musisz od razu robić pełnego „GraphRAG global”. Najpierw używaj grafu do: (a) definicji i wyjątków, (b) dołączania kontekstu „nadrzędna sekcja” / „poprzedninastępny”, (c) audytu ścieżki cytowań. Dopiero potem dokładaj stricte grafowe retrievery. citeturn10search6turn10search31turn3search4
**Krok piąty: wprowadź gating i rollout.**
Zgodnie z najlepszymi praktykami ewaluacji: iteruj, porównuj wersje, ustaw continuous evaluation i progi akceptacji. citeturn13search3turn13search35
### Proponowana sekwencja wdrożenia
| Faza | Co dostarczasz | Typowy czas (brak constraintów) | Kryterium „done” |
|---|---|---:|---|
| Audit RAG | logi + golden set + baseline metryk | 12 tyg. | masz mierzalne recall/faithfulness + top failure modes |
| Metadata-first | proweniencja + filtry + ACL | 12 tyg. | brak „orphan” chunków bez źródła; prefiltrowanie działa |
| Nodes & map | węzły typowane + relacje | 24 tyg. | stable IDs, relacje prev/next/contains/refers_to |
| Hybrid + rerank | dense+sparse + rerank topN | 13 tyg. | poprawa metryk retrieval bez wzrostu halucynacji |
| Graph expansion | dołączanie kontekstu grafem | 24 tyg. | poprawa trudnych pytań „łączących fakty” |
| Produkcja | monitoring KPI + procedury incydentów | ciągłe | CE + alerty + playbook audytu |
Metryki i praktykę continuous evaluation wspiera dokumentacja OpenAI (zalecenia dot. progów context recall/precision i pipelineu ewaluacji), co jest spójne z „kontrolą” jako procesem, nie jednorazową konfiguracją. citeturn13search3turn13search27
## Implementacje przykładowe
Poniższe implementacje są „szkieletami” (reference implementations). W obu wariantach zakładam brak narzuconych wymagań co do skali, więc pokazuję rozwiązania, które da się skalować horyzontalnie (wektor DB) i/lub uprościć (pgvector zamiast osobnej bazy).
### Stack A: opensource embeddings + opensource Vector DB (Sentence Transformers + Qdrant) + graf w Neo4j
**Kiedy wybrać:** gdy chcesz uniezależnić embeddingi od dostawcy, mieć pełną kontrolę nad danymi i implementować multitenancy/filtry wprost w wektor DB. Payload/filtry i multitenancy są natywnie wspierane w Qdrant. citeturn11search6turn14search1turn14search7
**Zależności (przykład):** `sentence-transformers`, `qdrant-client`, `neo4j`, parser dokumentów (`unstructured` lub Tika).
```python
# --- Ingestion: parse -> units -> embeddings -> Qdrant + graph ---
from dataclasses import dataclass
from typing import Iterable, Optional
import hashlib
import time
from sentence_transformers import SentenceTransformer, CrossEncoder
from qdrant_client import QdrantClient, models as qmodels
from neo4j import GraphDatabase
@dataclass
class KnowledgeUnit:
unit_id: str
source_id: str
version: str
unit_type: str # e.g. "section", "definition", "table"
text: str # canonical text for embedding
page: Optional[int] = None
section_path: Optional[str] = None
acl: str = "public" # e.g. role/tenant tag
def stable_id(source_id: str, version: str, unit_type: str, page: str, text: str) -> str:
raw = f"{source_id}|{version}|{unit_type}|{page}|{text}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:24]
# 1) Embeddings (bi-encoder) + reranker (cross-encoder)
embed_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2") # example
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") # example
# 2) Vector DB: Qdrant
qdrant = QdrantClient(url="http://localhost:6333", timeout=30)
COLLECTION = "kmrag_units"
DIM = embed_model.get_sentence_embedding_dimension()
qdrant.recreate_collection(
collection_name=COLLECTION,
vectors_config=qmodels.VectorParams(size=DIM, distance=qmodels.Distance.COSINE),
)
# Index payload fields frequently used in filters (performance)
qdrant.create_payload_index(
collection_name=COLLECTION,
field_name="acl",
field_schema=qmodels.PayloadSchemaType.KEYWORD,
)
qdrant.create_payload_index(
collection_name=COLLECTION,
field_name="source_id",
field_schema=qmodels.PayloadSchemaType.KEYWORD,
)
# 3) Graph DB: Neo4j (property graph)
neo4j_driver = GraphDatabase.driver(
"neo4j://localhost:7687", auth=("neo4j", "password")
)
def upsert_units(units: Iterable[KnowledgeUnit]) -> None:
batch = list(units)
# embeddings
vectors = embed_model.encode([u.text for u in batch], normalize_embeddings=True)
# upsert into Qdrant with payload metadata (provenance + ACL)
qdrant.upsert(
collection_name=COLLECTION,
points=[
qmodels.PointStruct(
id=u.unit_id,
vector=vectors[i].tolist(),
payload={
"source_id": u.source_id,
"version": u.version,
"unit_type": u.unit_type,
"page": u.page,
"section_path": u.section_path,
"acl": u.acl,
"ingested_at": int(time.time()),
},
)
for i, u in enumerate(batch)
],
)
# build/update graph nodes + relationships
cypher = """
UNWIND $rows AS r
MERGE (d:Document {source_id: r.source_id, version: r.version})
MERGE (u:Unit {unit_id: r.unit_id})
SET u.unit_type = r.unit_type,
u.page = r.page,
u.section_path = r.section_path
MERGE (d)-[:HAS_UNIT]->(u)
"""
with neo4j_driver.session() as s:
s.run(cypher, rows=[u.__dict__ for u in batch])
# --- Query-time retrieval: vector -> graph expansion -> rerank -> context ---
def retrieve(query: str, acl: str, top_k: int = 30, rerank_k: int = 8):
qvec = embed_model.encode([query], normalize_embeddings=True)[0].tolist()
# 1) Candidate generation with metadata filter (permission-aware)
hits = qdrant.search(
collection_name=COLLECTION,
query_vector=qvec,
limit=top_k,
query_filter=qmodels.Filter(
must=[qmodels.FieldCondition(key="acl", match=qmodels.MatchValue(value=acl))]
),
)
candidate_ids = [h.id for h in hits]
# 2) Graph expansion: pull neighbor units from same document/section (simple example)
expand_cypher = """
MATCH (u:Unit) WHERE u.unit_id IN $ids
OPTIONAL MATCH (d:Document)-[:HAS_UNIT]->(u)
OPTIONAL MATCH (d)-[:HAS_UNIT]->(u2:Unit)
WHERE u2.section_path = u.section_path
RETURN DISTINCT u2.unit_id AS unit_id
LIMIT 200
"""
with neo4j_driver.session() as s:
rows = s.run(expand_cypher, ids=candidate_ids).data()
expanded_ids = list({r["unit_id"] for r in rows}) or candidate_ids
# 3) Fetch texts for reranking (here: from Qdrant payload 'text' not stored; you'd load from your storage)
# In production: keep canonical text in your doc store; Qdrant payload keeps provenance only.
# For demo: assume we can map id->text elsewhere:
id_to_text = load_texts(expanded_ids) # implement in your system
pairs = [(query, id_to_text[i]) for i in expanded_ids]
scores = reranker.predict(pairs)
ranked = sorted(zip(expanded_ids, scores), key=lambda x: x[1], reverse=True)[:rerank_k]
return ranked # list of (unit_id, score) + you can also return provenance from payload
def load_texts(unit_ids):
# Placeholder: pull canonical text from your document store / data lake
raise NotImplementedError
```
Co w tym szkielecie jest „Mapą Wiedzy”: Neo4j przechowuje relacje (Document→Unit, a dalej możesz dodać Entity↔Unit, REFERENCES, NEXT), a Qdrant przechowuje wektory + payload do filtrowania; filtrowanie i indeksowanie payload jest sformalizowane w dokumentacji Qdrant. citeturn11search6turn11search2turn14search7
Rerank to klasyczny krok „retrievethenrerank” opisywany przez Sentence Transformers, gdzie CrossEncoder podnosi jakość finalnych wyników kosztem obliczeń. citeturn12search11turn12search19
### Stack B: managed LLM + Vector DB (OpenAI + Pinecone) + graf (Neo4j GraphRAG / Text2Cypher)
**Kiedy wybrać:** gdy zależy Ci na szybkości iteracji, jakości modeli oraz gotowych mechanizmach „structured output”, a retrieval chcesz oprzeć o managed vector DB z namespaces i hybrid search. citeturn13search1turn14search16turn11search3
W wariancie managed sensownie jest też wykorzystać Structured Outputs do wymuszenia formatu odpowiedzi (np. `answer` + `citations[]`), co jest elementem „kontroli” i audytu. OpenAI opisuje Structured Outputs jako mechanizm gwarantujący zgodność odpowiedzi z JSON Schema. citeturn13search1turn13search8
```python
# --- Managed stack: OpenAI embeddings + Pinecone + structured outputs + graph retrieval ---
from openai import OpenAI
from pinecone import Pinecone
from neo4j_graphrag import GraphRAG # example usage; adjust to actual package API
OPENAI_MODEL_EMB = "text-embedding-3-large"
OPENAI_MODEL_GEN = "gpt-5.4-mini" # example; choose by latency/cost needs
client = OpenAI()
pc = Pinecone(api_key="PINECONE_API_KEY")
index = pc.Index("kmrag")
def embed(texts):
resp = client.embeddings.create(model=OPENAI_MODEL_EMB, input=texts)
return [d.embedding for d in resp.data]
def upsert_to_pinecone(units, namespace):
vecs = embed([u["text"] for u in units])
index.upsert(
vectors=[
(u["unit_id"], vecs[i], {
"source_id": u["source_id"],
"version": u["version"],
"unit_type": u["unit_type"],
"page": u.get("page"),
"section_path": u.get("section_path"),
"acl": u.get("acl"),
})
for i, u in enumerate(units)
],
namespace=namespace, # multitenancy / workspace isolation
)
def retrieve_candidates(query, namespace, acl, top_k=30):
qvec = embed([query])[0]
res = index.query(
vector=qvec,
top_k=top_k,
include_metadata=True,
namespace=namespace,
filter={"acl": {"$eq": acl}},
)
return res["matches"]
# Optional: graph retrieval pattern via Text2Cypher (Neo4j GraphRAG package)
# The idea: use graph schema + question -> generated Cypher -> execute -> return records as extra grounded context.
gr = GraphRAG(neo4j_uri="neo4j+s://...", user="neo4j", password="...")
ANSWER_SCHEMA = {
"name": "kmrag_answer",
"schema": {
"type": "object",
"properties": {
"answer": {"type": "string"},
"citations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"unit_id": {"type": "string"},
"source_id": {"type": "string"},
"quote": {"type": "string"}
},
"required": ["unit_id", "source_id"]
}
},
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["answer", "citations", "confidence"]
}
}
def answer(query, namespace, acl):
hits = retrieve_candidates(query, namespace, acl)
text_context = "\n\n".join(
f"[{m['id']}] ({m['metadata'].get('source_id')}) {load_unit_text(m['id'])}"
for m in hits[:8]
)
graph_context = gr.text2cypher_retrieve(query) # e.g. definitions, relationships
system = (
"Odpowiadasz wyłącznie na podstawie kontekstu i grafu.\n"
"Jeśli brakuje danych, powiedz wprost, czego nie wiesz.\n"
"Zwróć cytowania do unit_id/source_id."
)
resp = client.responses.create(
model=OPENAI_MODEL_GEN,
input=[
{"role": "system", "content": system},
{"role": "user", "content": f"Pytanie: {query}\n\nKontekst:\n{text_context}\n\nGraf:\n{graph_context}"}
],
text={
"format": {
"type": "json_schema",
"json_schema": {**ANSWER_SCHEMA, "strict": True}
}
}
)
return resp.output_text
def load_unit_text(unit_id):
# fetch canonical unit text from your storage
raise NotImplementedError
```
Źródła dla tego stosu: OpenAI opisuje nowe modele embeddingowe (`text-embedding-3-small/large`) i guide embeddings, a także Structured Outputs i evaluation best practices. citeturn13search2turn13search1turn13search3turn13search9
Pinecone opisuje hybrydę oraz filtrowanie po metadanych i multitenancy przez namespaces. citeturn11search3turn11search7turn14search16
Wzorzec Text2Cypher tłumaczenie pytania + schematu grafu na Cypher i wykonanie query jest opisany w materiałach Neo4j. citeturn10search2turn10search6turn10search10
## Kontrola jakości, audyt i monitoring
„Kontrola zamiast nadziei” warto potraktować jako trzy warstwy: (A) kontrola danych i retrieval, (B) kontrola generacji, (C) kontrola procesu (ewaluacja i monitoring).
### Metryki i ewaluacja
**Ewaluacja retrieval** (czyt. „czy przynosimy właściwy kontekst”)
- Recall@K / Precision@K / MRR / NDCG: standardowe metryki IR; w pracach o retrieval z grafami i/lub KG są one explicite używane do oceny retrieval (np. praca o RAG+KG dla customer service raportuje MRR/Recall@K/NDCG@K). citeturn10search1turn10search5
- Offline test set buduj iteracyjnie na podstawie prawdziwych porażek (failure traces) to jest zgodne z podejściem „evaluation flywheel” i continuous evaluation. citeturn13search35turn13search3
**Ewaluacja generation** (czyt. „czy odpowiedź jest ugruntowana w źródłach”)
- RAGAS: framework do „referencefree evaluation” RAG, mierzący różne wymiary retrieval i generation. citeturn4search1turn4search5
- TruLens: RAG triad context relevance, groundedness, answer relevance jako praktyczny zestaw ocen dla halucynacji. citeturn4search2turn4search6
**Progi jakości (przykład)**
OpenAI w evaluation best practices podaje przykładowe targety (np. context recall ≥ 0.85, context precision > 0.7) jako część praktyk ewaluacji i porównywania wersji. Traktuj to jako punkt startowy, nie prawo natury. citeturn13search3
### Checklist audytu KMRAG
**Dane i ingestion**
- Czy parser zachowuje strukturę (sekcje/tabele/numery stron) i czy masz testy parsera na „trudnych dokumentach” (tabele, wielokolumnowe layouty)? citeturn16search0turn16search10turn6view0
- Czy każda jednostka wiedzy ma stabilne `source_id`, `version`, lokalizację i politykę retencji/PII? citeturn7view1turn14search2
**Mapa Wiedzy**
- Czy graf ma jasno zdefiniowane typy węzłów i relacje (HAS_UNIT, DEFINES, EXCEPTION_OF, REFERENCES, NEXT), oraz czy masz reguły walidacji (np. brak cykli w „NEXT”, spójność sekcji)? citeturn2view1turn10search31
- Czy ekstrakcja encji/relacji jest mierzalna (precision/recall) i odporna na duplikaty/rozbieżności nazw? (w praktyce: canonicalization + entity resolution). Koncepcja grafu encji jako indeksu jest centralna w GraphRAG. citeturn0search1turn0search13
**Retrieval**
- Czy stosujesz prefilter po ACL/tenant (permission-aware retrieval), zanim cokolwiek trafi do prompta? (mechanizmy namespaces/tenants i DLS/RBAC istnieją w narzędziach retrieval). citeturn14search16turn14search0turn14search2
- Czy masz hybrydę dense+sparse tam, gdzie słowa kluczowe są krytyczne (regulacje, numery, tickery)? Pinecone i Weaviate opisują hybrydę jako fuzję wyników. citeturn11search3turn12search0
- Czy reranking działa na topN, a nie na setkach wyników (koszt/latencja), i czy jest mierzony? citeturn12search11turn12search19
**Generacja i grounding**
- Czy model ma jasną instrukcję „answer from sources” oraz czy odpowiedź wymusza strukturę (JSON schema) i cytowania? Structured Outputs jest mechanizmem wspierającym niezawodność formatu. citeturn13search1turn13search8
- Czy masz mechanizm „I dont know / insufficient evidence” zamiast konfabulacji (np. minimalny próg evidence coverage)? Podejścia typu CoVe/SelfRAG/CRAG pokazują, że pętle weryfikacji i korekty podnoszą factuality. citeturn8search3turn9search1turn9search2
**Bezpieczeństwo**
- Czy testujesz prompt injection na poziomie aplikacji, nie tylko promptu? OWASP opisuje prompt injection jako manipulację zachowaniem modelu przez wejście, a cheat sheet sugeruje praktyki obrony. citeturn4search3turn4search7turn4search13
- Czy masz kontrolę kosztu (rate limits, timeouts, budżety tokenów) to też „kontrola”, bo DoS na LLM to realny wektor ryzyka (OWASP LLM Top 10 zawiera kategorie dot. DoS i supply chain). citeturn4search13turn13search12
### KPI i monitoring w produkcji
Rekomendowany zestaw KPI (z podziałem na warstwy):
**Retrieval KPI**
- Context Recall@K / Context Precision@K (offline i online na próbie logów). citeturn13search3turn4search1
- % zapytań, w których retrieval zwraca „pustkę” lub tylko niskie score (sugeruje routing lub brak danych).
**Generation KPI**
- Faithfulness/groundedness (TruLens/RAGAS). citeturn4search1turn4search6
- Citation coverage: % zdań mających przypisane źródło, oraz „citation accuracy” (czy cytat faktycznie zawiera wspierający fragment). SelfRAG raportuje poprawę citation accuracy w długich generacjach jako jeden z efektów frameworku. citeturn9search1turn9search9
**Ops KPI**
- Latencja p95/p99 per etap (retrieval, rerank, LLM).
- Koszt per zapytanie (tokeny, liczba wywołań modeli) + alerty „unbounded consumption”. OpenAI publikuje production best practices i evaluation tooling jako część przejścia prototyp → produkcja. citeturn13search12turn13search3
**Narzędzia do obserwowalności**
- RAGAS opisuje łączenie ewaluacji z tracingiem/analizą (np. Phoenix). citeturn4search34
- TruLens ma integracje i dokumentację quickstart dla trace + feedback. citeturn4search2turn4search27
- Jeśli używasz OpenAI, masz też guidance dot. ewaluacji i ciągłego monitorowania regresji. citeturn13search3turn13search6
### Typowe failure modes KMRAG i mitigacje
**„Graf rośnie w chaos” (sprawl, duplikaty encji, zła kanonikalizacja).**
Mitigacja: wprowadź entity resolution, reguły normalizacji nazw, walidację schematu grafu i testy na podzbiorze; zacznij od grafu dokumentsekcjaunit, dopiero potem dodawaj encje/relacje automatyczne. GraphRAG wprost zaczyna od grafu encji jako indeksu, ale też pipelineu budowy i transformacji danych, co sugeruje konieczność procesu, nie jednorazowego prompta. citeturn3search0turn0search1
**„Retrieval jest poprawny semantycznie, ale zły merytorycznie” (conflicts).**
Mitigacja: hybryda dense+sparse + rerank + kontrola jakości źródeł + mechanizmy korekty (CRAG: evaluator jakości retrieval i akcje naprawcze). citeturn9search2turn11search3turn12search0
**„Źródła przenoszą instrukcje (prompt injection z dokumentów)”**
Mitigacja: separacja „instructions vs data”, sanitation, polityki „nie wykonuj instrukcji z kontekstu”, oraz przede wszystkim permission-aware retrieval (prefilter). OWASP opisuje prompt injection i praktyki obrony. citeturn4search3turn4search7turn14search2
**„Latency/cost eksploduje przez reranking i graf”**
Mitigacja: ogranicz N rerankowanych kandydatów; cache embeddingów; cache wyników graf expansion; prestreszczenia (GraphRAG community summaries) dla klas pytań globalnych. citeturn12search11turn0search1turn3search4
**„Zgodność i audyt”**
Mitigacja: loguj trace: query → (filtry ACL) → dokumenty → fragmenty → odpowiedź; uzupełnij o standardy zarządzania ryzykiem i bezpieczeństwem (entity["organization","NIST","us standards institute"] AI RMF; entity["organization","ISO","international standards body"]/IEC 27001; entity["organization","OWASP","security foundation"] LLM Top 10). Zapewnia to język kontroli dla audytu, nawet jeśli implementacje są różne. citeturn15search1turn15search2turn4search13turn15search3
### Źródła priorytetowe do dalszej pracy
Najbardziej „nośne” (loadbearing) źródła do wdrożenia KMRAG, w kolejności praktycznej użyteczności:
Źródła autora: definicja Mapy Wiedzy (graf + metadane + odniesienia) oraz argument o „R jako ryzyku” i potrzebie kontroli retrieval. citeturn2view1turn7view1
Podstawy RAG: praca Lewis et al. (RAG jako retrieval + generacja z nieparametrycznej pamięci) jako fundament terminologiczny. citeturn0search2turn0search5
GraphRAG: publikacja i repozytorium Microsoft (graf encji, społeczności, streszczenia) jako referencyjny wariant Mapy Wiedzy w postaci pipelineu. citeturn0search1turn3search0turn3search4turn3search20
KGRAG / hybrydy: prace o łączeniu KG i RAG (np. HybridRAG; RAG+KG w customer service) pokazują, że graf zmniejsza skutki segmentacji i poprawia retrieval. citeturn10search0turn10search1
Ewaluacja i kontrola jakości: RAGAS + TruLens + best practices ewaluacji jako praktyczny „system kontroli”. citeturn4search1turn4search2turn13search3
Bezpieczeństwo: OWASP prompt injection i LLM Top 10 jako checklisty dla warstwy „R” i integracji z danymi. citeturn4search3turn4search13turn4search7
@@ -0,0 +1,23 @@
# Quality and Evaluation Checklist
To move from "hope-based RAG" to "controlled RAG", implement these checks.
## 1. Retrieval Metrics (Search Quality)
- [ ] **Context Recall**: Are the units necessary to answer the question actually in the retrieved set?
- [ ] **Context Precision**: Is the retrieved set clean of irrelevant noise?
- [ ] **MRR (Mean Reciprocal Rank)**: Is the most relevant unit appearing at the top?
## 2. Generation Metrics (Answer Quality)
- [ ] **Faithfulness (Groundedness)**: Can every claim in the answer be traced to a retrieved Knowledge Unit?
- [ ] **Answer Relevance**: Does the answer actually address the user's intent?
- [ ] **Citation Accuracy**: Do the citations correctly point to the unit that supports the claim?
## 3. Governance & Safety
- [ ] **ACL Pre-Filtering**: Is there a hard check ensuring units from different tenants/roles are NEVER mixed?
- [ ] **PII Scanning**: Are units scanned for sensitive data during ingestion?
- [ ] **Hallucination Gating**: Is there a "Confidence Score" or "Low Evidence" flag to warn users?
## 4. Operational Health
- [ ] **Latency Monitoring**: Break down time spent in: Embedding -> Vector Search -> Graph Expansion -> Reranking -> LLM.
- [ ] **Token Efficiency**: Are we sending unnecessary fluff to the LLM, or is the context tightly packed with relevant units?
- [ ] **Index Drift**: Are we re-evaluating the "Golden Set" of questions when we update embedding models or chunking strategies?
@@ -0,0 +1,89 @@
# Implementation Patterns for KM-RAG in .NET
This guide outlines how to implement KM-RAG patterns using C# and .NET, building on existing infrastructures like EF Core and `Microsoft.Extensions.AI`.
## 1. Defining Knowledge Units
Represent units as strongly-typed entities to capture metadata and relationships.
```csharp
public enum KnowledgeUnitType { Section, Table, Definition, Step, Rule }
public class KnowledgeUnit
{
public string Id { get; set; } // Stable Hash(Source, Content, Version)
public string SourceId { get; set; }
public string Version { get; set; }
public KnowledgeUnitType Type { get; set; }
public string Content { get; set; }
public string MetadataJson { get; set; } // page, section_path, etc.
public Vector? Embedding { get; set; }
// Graph Relationships
public List<KnowledgeUnitLink> OutgoingLinks { get; set; } = new();
}
public class KnowledgeUnitLink
{
public string TargetUnitId { get; set; }
public string RelationType { get; set; } // "Next", "Defines", "References"
}
```
## 2. Multi-Stage Retrieval
Transition from simple `Take(Limit)` to a pipeline.
### Step A: Hybrid Candidate Generation
Combine `pgvector` cosine similarity with full-text search if available.
```csharp
var queryVector = await _embeddingGenerator.GenerateAsync(queryText);
var candidates = await _dbContext.KnowledgeUnits
.Where(u => u.TenantId == tenantId)
.OrderBy(u => u.Embedding.CosineDistance(queryVector))
.Take(20) // Get more candidates for reranking
.Select(u => new { u.Id, u.Content, u.Type })
.ToListAsync();
```
### Step B: Graph Expansion
Retrieve related units to provide full context.
```csharp
// Example: Get "Contextual Neighbors"
var expandedIds = await _dbContext.KnowledgeUnitLinks
.Where(l => candidateIds.Contains(l.SourceUnitId) && l.RelationType == "ParentSection")
.Select(l => l.TargetUnitId)
.Distinct()
.ToListAsync();
var contextUnits = await _dbContext.KnowledgeUnits
.Where(u => expandedIds.Contains(u.Id))
.ToListAsync();
```
## 3. Reranking and Citations
Use a model to score the relevance of the expanded context and ensure the LLM cites sources.
```csharp
// System Prompt for Grounded Generation
var systemPrompt = @"
You are a precision assistant. Answer ONLY using the provided Knowledge Units.
If the information is missing, state 'Information not found in knowledge map'.
Each answer segment MUST include a citation in format [UnitId].
";
// Response Structure (using System.Text.Json or Structured Outputs)
public class RagResponse
{
public string Answer { get; set; }
public List<Citation> Citations { get; set; }
}
```
## 4. Ingestion Workflow
Instead of `string.Split`, use structural parsers:
1. **Parse**: Extract sections/tables (e.g., using `Unstructured` or custom Logic).
2. **Normalize**: Assign stable IDs based on content hash + source metadata.
3. **Embed**: Generate vectors for the canonical text of each unit.
4. **Relate**: Build links (e.g., `prev` -> `curr` -> `next`).
@@ -8,6 +8,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy
- `NexusReader.Domain`: Enterprise business rules (Entities, Value Objects, Domain Events). - `NexusReader.Domain`: Enterprise business rules (Entities, Value Objects, Domain Events).
- `NexusReader.Application`: Application business rules (Commands, Queries, DTOs, Mappings, Interfaces). - `NexusReader.Application`: Application business rules (Commands, Queries, DTOs, Mappings, Interfaces).
- `NexusReader.Infrastructure`: Data access, external services, and platform-specific implementations. - `NexusReader.Infrastructure`: Data access, external services, and platform-specific implementations.
- **Persistence**: Use `IDbContextFactory<AppDbContext>` for long-running operations or when multiple units of work are needed in a single scope (especially in Blazor).
- `NexusReader.UI.Shared`: UI logic and Blazor components. - `NexusReader.UI.Shared`: UI logic and Blazor components.
- `NexusReader.Maui` / `NexusReader.Web`: Platform host projects. - `NexusReader.Maui` / `NexusReader.Web`: Platform host projects.
@@ -16,6 +17,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy
- **Queries**: Read-only operations, return `Task<Result<T>>`. - **Queries**: Read-only operations, return `Task<Result<T>>`.
- **Commands**: State-changing operations, return `Task<Result>` or `Task<Result<T>>`. - **Commands**: State-changing operations, return `Task<Result>` or `Task<Result<T>>`.
- **Handlers**: Located in `Application` layer, grouped by feature (e.g., `Queries/Reader/...`). - **Handlers**: Located in `Application` layer, grouped by feature (e.g., `Queries/Reader/...`).
- **Client-Server Boundaries**: DO NOT execute MediatR handlers directly from WASM/MAUI clients if the handler relies on server-only infrastructure (e.g., `AppDbContext`, `IHubContext`). Instead, the client must trigger an API or SignalR endpoint, and the server dispatches the MediatR command.
- **Functional Error Handling:** - **Functional Error Handling:**
- Mandatory use of `FluentResults`. - Mandatory use of `FluentResults`.
@@ -36,3 +38,6 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy
- **Cross-Platform Strategy:** - **Cross-Platform Strategy:**
- Maximize code sharing in `NexusReader.UI.Shared`. - Maximize code sharing in `NexusReader.UI.Shared`.
- Use `IPlatformService` (or similar abstractions) for native features, implemented in `Infrastructure.Mobile` or Maui projects. - Use `IPlatformService` (or similar abstractions) for native features, implemented in `Infrastructure.Mobile` or Maui projects.
- **Code Validation (CRITICAL):**
- **Mandatory Build Verification**: After any code change, the agent MUST run `dotnet build` on the solution. The agent must verify that the build completes with `Exit code: 0` and without errors before concluding the task or requesting user feedback.
+33
View File
@@ -0,0 +1,33 @@
---
name: nexus-code-review
description: Code Review Checklist and Standards for NexusReader SaaS
---
# NexusReader Code Review Standards
When conducting or receiving a code review for NexusReader, ensure the implementation adheres to the following critical architectural and performance standards:
## 1. Architectural Boundaries (CQRS & Blazor Hybrid)
- [ ] **Client vs. Server Execution**: MediatR handlers that depend on server-side infrastructure (`AppDbContext`, `IHubContext`, secrets) MUST NOT be executed directly from client environments (WASM/MAUI).
- [ ] **Dependency Leakage**: Ensure `NexusReader.Web.Client` (WASM) does not reference `NexusReader.Infrastructure` if the infrastructure requires `Microsoft.AspNetCore.App` framework references.
- [ ] **SignalR Bridges**: Client-initiated state changes should be sent via SignalR `SendAsync` to a server Hub, which then dispatches the internal `MediatR` command.
## 2. Event Handling & Debouncing
- [ ] **High-Frequency UI Events**: UI actions like scrolling, resizing, or typing must be debounced.
- [ ] **Trailing-Edge Debounce**: Use a `CancellationTokenSource` and `Task.Delay` to ensure the *last* event in a rapid sequence is executed. Do not use simple time-window drops, as they result in lost final states.
- [ ] **Async Void**: Ensure UI event handlers do not use `async void` unless they are top-level framework event bindings, and even then, they must catch all exceptions.
## 3. SignalR & Real-Time Contexts
- [ ] **Authentication Context**: Do not rely on `IHttpContextAccessor` inside MediatR handlers triggered by SignalR Hubs. Use `Context.UserIdentifier` directly from the Hub and pass it as a command parameter.
- [ ] **Connection State**: Always check `HubConnection.State == HubConnectionState.Connected` before attempting to send messages from the client.
- [ ] **Targeted Broadcasting**: Use SignalR `Groups` (e.g., `$"User_{userId}"`) to broadcast updates only to the devices owned by the relevant user.
## 4. Performance & Scalability
- [ ] **Database Write Contention**: High-frequency telemetry (like reading progress) should ideally be batched or cached in-memory before writing to SQL, unless real-time persistence is strictly required.
- [ ] **Memory Leaks**: Ensure all components and services that subscribe to events (e.g., `OnProgressReceived`, JS Observers) implement `IDisposable` or `IAsyncDisposable` and properly unsubscribe.
## 5. Standard Nexus Guidelines
- [ ] **Result Pattern**: Ensure all application logic returns `Result` or `Result<T>` via FluentResults. No exceptions for control flow.
- [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`.
## 6. Code Review Comments
- [ ] **Specific Linking**: Comments should be linked to specific code. Try to avoid general comments about the entire pull request.
@@ -0,0 +1,140 @@
---
name: nexus-dotnet-architect
description: Guides the development of production-grade .NET 10 APIs and microservices for the Nexus project, enforcing Clean Architecture, CQRS, Result Pattern, Mapster, no async void, specific project standards like Multi-Tenancy and EF Core migrations, and backend development best practices like caching, resilience, observability, and AI-powered code analysis. Use when building backend services or APIs within the Nexus ecosystem.
---
# Nexus Dotnet Architect Skill
## Overview
This skill provides expert guidance for developing production-grade .NET 10 APIs and microservices within the Nexus project ecosystem. It enforces a strict adherence to the defined architecture, technical constraints, and development workflow, ensuring high performance, maintainability, and scalability.
## Core Principles & Constraints
This skill mandates the following architectural and development standards:
### Architecture
- **Clean Architecture:** Strict separation of concerns: `Domain` -> `Application` <- `Infrastructure`.
- **CQRS Pattern:** Mandatory use of `MediatR`. All business logic must reside in handlers, not UI components.
- **Result Pattern:** Zero exceptions for flow control. All handlers must return `Result<T>` via `FluentResult`.
- **Mapping:** Exclusive use of `Mapster`. No other mapping libraries are permitted.
### Technical Constraints
- **Platform:** Target .NET 10 with **Native AOT** compatibility. Optimize for mobile performance.
- **UI Framework:** Blazor Component Model. Use isolated Razor Components (`.razor` + `.razor.css`). No raw HTML/CSS in components.
- **Directory Structure:** `/src` for application code and `/tests` for testing code at the solution root level.
### Development Workflow
- **Verification-Led:** Define tests and verification steps *before* writing feature code.
- **Step-by-Step Execution:** Break complex tasks into manageable, verifiable chunks.
- **Layer Integrity:** Constantly check for and prevent illegal cross-layer dependencies (e.g., `Application` depending on `Infrastructure`).
- **Mandatory Build Gate:** After **every** code change, run `dotnet build NexusReader.slnx --no-restore` from the solution root. The agent must not proceed if there are any `error CS*` compiler errors. Build warnings are acceptable.
### API & Microservice Focus
- Develop production-grade APIs and microservices using C# and ASP.NET Core.
- Leverage modern C# features.
- Implement robust data access patterns, including EF Core and Dapper.
- Incorporate caching strategies and performance optimization.
## Project Specific Standards
### Multi-Tenancy (Tenant Isolation)
- Every entity related to user data MUST have a `TenantId` property.
- Every query MUST filter by `TenantId` to prevent data leakage.
- Default `TenantId` is "global" for shared resources.
### Database Schema Changes
- Every change to a Domain entity or DbContext MUST be followed by the generation of a new EF Core migration.
- **Mandatory Commands**:
- `dotnet ef migrations add <MigrationName> --project src/NexusReader.Data --startup-project src/NexusReader.Web`
- `dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web`
- Ensure the migration is applied to all local development environments before proceeding with feature verification.
### Auditing & Verification
- **Audit Scripts:** Use `src/.agent/skills/nexus-architecture-standards/scripts/arch_check.sh` (or equivalent logic) to scan for illegal cross-layer imports. This script should be run regularly to maintain layer integrity.
- **Reference Materials:** Refer to `src/.agent/skills/nexus-architecture-standards/artifacts/layer_matrix.md` for a clear definition of layer dependencies.
## Backend Development Patterns
### Architecture & Design
- **API Design:** Follow RESTful principles, use clear and consistent naming conventions.
- **Microservices Principles:** Design for independent deployability, scalability, and fault isolation.
- **Domain-Driven Design (DDD):** Apply DDD concepts where appropriate to model complex business domains.
### Dependency Injection
- Utilize the built-in .NET Core Dependency Injection container.
- Register services with appropriate lifetimes (Scoped, Singleton, Transient).
- Prefer constructor injection.
### Caching
- Implement distributed caching using **Redis** for improved performance and reduced database load.
- Apply caching strategies judiciously (e.g., cache-aside, read-through, write-through).
### Database Optimization
- **Entity Framework Core (EF Core):** Optimize queries, use `AsNoTracking()`, leverage projections, and manage migrations effectively.
- **Dapper:** Utilize Dapper for performance-critical queries where EF Core might be too slow.
- **Connection Pooling:** Ensure database connections are managed efficiently.
### Resilience Patterns
- **Retry Policies:** Implement retry logic for transient failures (e.g., network issues, temporary service unavailability) using libraries like Polly.
- **Circuit Breaker:** Protect against cascading failures by implementing circuit breaker patterns.
- **Timeouts:** Configure appropriate timeouts for external service calls and database operations.
### Observability
- **Logging:** Implement structured logging using a robust logging framework (e.g., Serilog).
- **Monitoring:** Integrate with monitoring solutions (e.g., Application Insights, Prometheus) for metrics and performance tracking.
- **Distributed Tracing:** Enable distributed tracing to track requests across multiple services.
## Code Review & Quality Assurance
### Static Analysis
- Scan code for common bugs, anti-patterns, and style violations.
- Ensure adherence to project coding standards.
### Security Review (OWASP)
- Identify potential security vulnerabilities based on OWASP Top 10 guidelines.
- Check for common security flaws like injection vulnerabilities, broken authentication, etc.
### Performance Optimization
- Analyze code for performance bottlenecks.
- Suggest improvements for efficiency and resource utilization.
### Infrastructure-as-Code (IaC) Assessment
- Review IaC definitions (e.g., Terraform, Dockerfile) for security and best practices.
## Review Process
The code reviewer follows a structured, 10-step approach to provide feedback:
1. **Understand Context:** Analyze the code and its purpose.
2. **Static Analysis:** Perform initial checks for common issues.
3. **Security Scan:** Identify potential security vulnerabilities.
4. **Performance Check:** Evaluate for performance bottlenecks.
5. **IaC Review:** Assess infrastructure code if applicable.
6. **Best Practices Check:** Verify adherence to established patterns.
7. **Constructive Feedback:** Provide clear, actionable suggestions.
8. **Prioritization:** Rank feedback by severity (critical, high, medium, low).
9. **Educational Tone:** Explain *why* a change is recommended.
10. **Final Summary:** Consolidate findings and recommendations.
## Resources
- **EF Core Best Practices:** See `references/ef-core-best-practices.md` for detailed guidance on optimizing EF Core usage.
- **Implementation Playbook:** Refer to `resources/implementation-playbook.md` for detailed examples and implementation guidance.
@@ -0,0 +1,355 @@
# Entity Framework Core Best Practices
Performance optimization and best practices for EF Core in production applications.
## Query Optimization
### 1. Use AsNoTracking for Read-Only Queries
```csharp
// ✅ Good - No change tracking overhead
var products = await _context.Products
.AsNoTracking()
.Where(p => p.CategoryId == categoryId)
.ToListAsync(ct);
// ❌ Bad - Unnecessary tracking for read-only data
var products = await _context.Products
.Where(p => p.CategoryId == categoryId)
.ToListAsync(ct);
```
### 2. Select Only Needed Columns
```csharp
// ✅ Good - Project to DTO
var products = await _context.Products
.AsNoTracking()
.Where(p => p.CategoryId == categoryId)
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
})
.ToListAsync(ct);
// ❌ Bad - Fetching all columns
var products = await _context.Products
.Where(p => p.CategoryId == categoryId)
.ToListAsync(ct);
```
### 3. Avoid N+1 Queries with Eager Loading
```csharp
// ✅ Good - Single query with Include
var orders = await _context.Orders
.AsNoTracking()
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
// ❌ Bad - N+1 queries (lazy loading)
var orders = await _context.Orders
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
foreach (var order in orders)
{
// Each iteration triggers a separate query!
var items = order.Items.ToList();
}
```
### 4. Use Split Queries for Large Includes
```csharp
// ✅ Good - Prevents cartesian explosion
var orders = await _context.Orders
.AsNoTracking()
.Include(o => o.Items)
.Include(o => o.Payments)
.Include(o => o.ShippingHistory)
.AsSplitQuery() // Executes as multiple queries
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
```
### 5. Use Compiled Queries for Hot Paths
```csharp
public class ProductRepository
{
// Compile once, reuse many times
private static readonly Func<AppDbContext, string, Task<Product?>> GetByIdQuery =
EF.CompileAsyncQuery((AppDbContext ctx, string id) =>
ctx.Products.AsNoTracking().FirstOrDefault(p => p.Id == id));
private static readonly Func<AppDbContext, int, IAsyncEnumerable<Product>> GetByCategoryQuery =
EF.CompileAsyncQuery((AppDbContext ctx, int categoryId) =>
ctx.Products.AsNoTracking().Where(p => p.CategoryId == categoryId));
public Task<Product?> GetByIdAsync(string id, CancellationToken ct)
=> GetByIdQuery(_context, id);
public IAsyncEnumerable<Product> GetByCategoryAsync(int categoryId)
=> GetByCategoryQuery(_context, categoryId);
}
```
## Batch Operations
### 6. Use ExecuteUpdate/ExecuteDelete (.NET 7+)
```csharp
// ✅ Good - Single SQL UPDATE
await _context.Products
.Where(p => p.CategoryId == oldCategoryId)
.ExecuteUpdateAsync(s => s
.SetProperty(p => p.CategoryId, newCategoryId)
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow),
ct);
// ✅ Good - Single SQL DELETE
await _context.Products
.Where(p => p.IsDeleted && p.UpdatedAt < cutoffDate)
.ExecuteDeleteAsync(ct);
// ❌ Bad - Loads all entities into memory
var products = await _context.Products
.Where(p => p.CategoryId == oldCategoryId)
.ToListAsync(ct);
foreach (var product in products)
{
product.CategoryId = newCategoryId;
}
await _context.SaveChangesAsync(ct);
```
### 7. Bulk Insert with EFCore.BulkExtensions
```csharp
// Using EFCore.BulkExtensions package
var products = GenerateLargeProductList();
// ✅ Good - Bulk insert (much faster for large datasets)
await _context.BulkInsertAsync(products, ct);
// ❌ Bad - Individual inserts
foreach (var product in products)
{
_context.Products.Add(product);
}
await _context.SaveChangesAsync(ct);
```
## Connection Management
### 8. Configure Connection Pooling
```csharp
services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null);
sqlOptions.CommandTimeout(30);
});
// Performance settings
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
// Development only
if (env.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});
```
### 9. Use DbContext Pooling
```csharp
// ✅ Good - Context pooling (reduces allocation overhead)
services.AddDbContextPool<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
}, poolSize: 128);
// Instead of AddDbContext
```
## Concurrency and Transactions
### 10. Handle Concurrency with Row Versioning
```csharp
public class Product
{
public string Id { get; set; }
public string Name { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } // SQL Server rowversion
}
// Or with Fluent API
builder.Property(p => p.RowVersion)
.IsRowVersion();
// Handle concurrency conflicts
try
{
await _context.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync(ct);
if (databaseValues == null)
{
// Entity was deleted
throw new NotFoundException("Product was deleted by another user");
}
// Client wins - overwrite database values
entry.OriginalValues.SetValues(databaseValues);
await _context.SaveChangesAsync(ct);
}
```
### 11. Use Explicit Transactions When Needed
```csharp
await using var transaction = await _context.Database.BeginTransactionAsync(ct);
try
{
// Multiple operations
_context.Orders.Add(order);
await _context.SaveChangesAsync(ct);
await _context.OrderItems.AddRangeAsync(items, ct);
await _context.SaveChangesAsync(ct);
await _paymentService.ProcessAsync(order.Id, ct);
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
```
## Indexing Strategy
### 12. Create Indexes for Query Patterns
```csharp
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
// Unique index
builder.HasIndex(p => p.Sku)
.IsUnique();
// Composite index for common query patterns
builder.HasIndex(p => new { p.CategoryId, p.Name });
// Filtered index (SQL Server)
builder.HasIndex(p => p.Price)
.HasFilter("[IsDeleted] = 0");
// Include columns for covering index
builder.HasIndex(p => p.CategoryId)
.IncludeProperties(p => new { p.Name, p.Price });
}
}
```
## Common Anti-Patterns to Avoid
### ❌ Calling ToList() Too Early
```csharp
// ❌ Bad - Materializes all products then filters in memory
var products = _context.Products.ToList()
.Where(p => p.Price > 100);
// ✅ Good - Filter in SQL
var products = await _context.Products
.Where(p => p.Price > 100)
.ToListAsync(ct);
```
### ❌ Using Contains with Large Collections
```csharp
// ❌ Bad - Generates massive IN clause
var ids = GetThousandsOfIds();
var products = await _context.Products
.Where(p => ids.Contains(p.Id))
.ToListAsync(ct);
// ✅ Good - Use temp table or batch queries
var products = new List<Product>();
foreach (var batch in ids.Chunk(100))
{
var batchResults = await _context.Products
.Where(p => batch.Contains(p.Id))
.ToListAsync(ct);
products.AddRange(batchResults);
}
```
### ❌ String Concatenation in Queries
```csharp
// ❌ Bad - Can't use index
var products = await _context.Products
.Where(p => (p.FirstName + " " + p.LastName).Contains(searchTerm))
.ToListAsync(ct);
// ✅ Good - Use computed column with index
builder.Property(p => p.FullName)
.HasComputedColumnSql("[FirstName] + ' ' + [LastName]");
builder.HasIndex(p => p.FullName);
```
## Monitoring and Diagnostics
```csharp
// Log slow queries
services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
options.LogTo(
filter: (eventId, level) => eventId.Id == CoreEventId.QueryExecutionPlanned.Id,
logger: (eventData) =>
{
if (eventData is QueryExpressionEventData queryData)
{
var duration = queryData.Duration;
if (duration > TimeSpan.FromSeconds(1))
{
_logger.LogWarning("Slow query detected: {Duration}ms - {Query}",
duration.TotalMilliseconds,
queryData.Expression);
}
}
});
});
```
@@ -0,0 +1,801 @@
# .NET Backend Development Patterns Implementation Playbook
This file contains detailed patterns, checklists, and code samples referenced by the skill.
## Core Concepts
### 1. Project Structure (Clean Architecture)
```
src/
├── Domain/ # Core business logic (no dependencies)
│ ├── Entities/
│ ├── Interfaces/
│ ├── Exceptions/
│ └── ValueObjects/
├── Application/ # Use cases, DTOs, validation
│ ├── Services/
│ ├── DTOs/
│ ├── Validators/
│ └── Interfaces/
├── Infrastructure/ # External implementations
│ ├── Data/ # EF Core, Dapper repositories
│ ├── Caching/ # Redis, Memory cache
│ ├── External/ # HTTP clients, third-party APIs
│ └── DependencyInjection/ # Service registration
└── Api/ # Entry point
├── Controllers/ # Or MinimalAPI endpoints
├── Middleware/
├── Filters/
└── Program.cs
```
### 2. Dependency Injection Patterns
```csharp
// Service registration by lifetime
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplicationServices(
this IServiceCollection services,
IConfiguration configuration)
{
// Scoped: One instance per HTTP request
services.AddScoped<IProductService, ProductService>();
services.AddScoped<IOrderService, OrderService>();
// Singleton: One instance for app lifetime
services.AddSingleton<ICacheService, RedisCacheService>();
services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(configuration["Redis:Connection"]!));
// Transient: New instance every time
services.AddTransient<IValidator<CreateOrderRequest>, CreateOrderValidator>();
// Options pattern for configuration
services.Configure<CatalogOptions>(configuration.GetSection("Catalog"));
services.Configure<RedisOptions>(configuration.GetSection("Redis"));
// Factory pattern for conditional creation
services.AddScoped<IPriceCalculator>(sp =>
{
var options = sp.GetRequiredService<IOptions<PricingOptions>>().Value;
return options.UseNewEngine
? sp.GetRequiredService<NewPriceCalculator>()
: sp.GetRequiredService<LegacyPriceCalculator>();
});
// Keyed services (.NET 8+)
services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");
return services;
}
}
// Usage with keyed services
public class CheckoutService
{
public CheckoutService(
[FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor)
{
_processor = stripeProcessor;
}
}
```
### 3. Async/Await Patterns
```csharp
// ✅ CORRECT: Async all the way down
public async Task<Product> GetProductAsync(string id, CancellationToken ct = default)
{
return await _repository.GetByIdAsync(id, ct);
}
// ✅ CORRECT: Parallel execution with WhenAll
public async Task<(Stock, Price)> GetStockAndPriceAsync(
string productId,
CancellationToken ct = default)
{
var stockTask = _stockService.GetAsync(productId, ct);
var priceTask = _priceService.GetAsync(productId, ct);
await Task.WhenAll(stockTask, priceTask);
return (await stockTask, await priceTask);
}
// ✅ CORRECT: ConfigureAwait in libraries
public async Task<T> LibraryMethodAsync<T>(CancellationToken ct = default)
{
var result = await _httpClient.GetAsync(url, ct).ConfigureAwait(false);
return await result.Content.ReadFromJsonAsync<T>(ct).ConfigureAwait(false);
}
// ✅ CORRECT: ValueTask for hot paths with caching
public ValueTask<Product?> GetCachedProductAsync(string id)
{
if (_cache.TryGetValue(id, out Product? product))
return ValueTask.FromResult(product);
return new ValueTask<Product?>(GetFromDatabaseAsync(id));
}
// ❌ WRONG: Blocking on async (deadlock risk)
var result = GetProductAsync(id).Result; // NEVER do this
var result2 = GetProductAsync(id).GetAwaiter().GetResult(); // Also bad
// ❌ WRONG: async void (except event handlers)
public async void ProcessOrder() { } // Exceptions are lost
// ❌ WRONG: Unnecessary Task.Run for already async code
await Task.Run(async () => await GetDataAsync()); // Wastes thread
```
### 4. Configuration with IOptions
```csharp
// Configuration classes
public class CatalogOptions
{
public const string SectionName = "Catalog";
public int DefaultPageSize { get; set; } = 50;
public int MaxPageSize { get; set; } = 200;
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(15);
public bool EnableEnrichment { get; set; } = true;
}
public class RedisOptions
{
public const string SectionName = "Redis";
public string Connection { get; set; } = "localhost:6379";
public string KeyPrefix { get; set; } = "mcp:";
public int Database { get; set; } = 0;
}
// appsettings.json
{
"Catalog": {
"DefaultPageSize": 50,
"MaxPageSize": 200,
"CacheDuration": "00:15:00",
"EnableEnrichment": true
},
"Redis": {
"Connection": "localhost:6379",
"KeyPrefix": "mcp:",
"Database": 0
}
}
// Registration
services.Configure<CatalogOptions>(configuration.GetSection(CatalogOptions.SectionName));
services.Configure<RedisOptions>(configuration.GetSection(RedisOptions.SectionName));
// Usage with IOptions (singleton, read once at startup)
public class CatalogService
{
private readonly CatalogOptions _options;
public CatalogService(IOptions<CatalogOptions> options)
{
_options = options.Value;
}
}
// Usage with IOptionsSnapshot (scoped, re-reads on each request)
public class DynamicService
{
private readonly CatalogOptions _options;
public DynamicService(IOptionsSnapshot<CatalogOptions> options)
{
_options = options.Value; // Fresh value per request
}
}
// Usage with IOptionsMonitor (singleton, notified on changes)
public class MonitoredService
{
private CatalogOptions _options;
public MonitoredService(IOptionsMonitor<CatalogOptions> monitor)
{
_options = monitor.CurrentValue;
monitor.OnChange(newOptions => _options = newOptions);
}
}
```
### 5. Result Pattern (Avoiding Exceptions for Flow Control)
```csharp
// Generic Result type
public class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
public string? ErrorCode { get; }
private Result(bool isSuccess, T? value, string? error, string? errorCode)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
ErrorCode = errorCode;
}
public static Result<T> Success(T value) => new(true, value, null, null);
public static Result<T> Failure(string error, string? code = null) => new(false, default, error, code);
public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
IsSuccess ? Result<TNew>.Success(mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
public async Task<Result<TNew>> MapAsync<TNew>(Func<T, Task<TNew>> mapper) =>
IsSuccess ? Result<TNew>.Success(await mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
}
// Usage in service
public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
// Validation
var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid)
return Result<Order>.Failure(
validation.Errors.First().ErrorMessage,
"VALIDATION_ERROR");
// Business rule check
var stock = await _stockService.CheckAsync(request.ProductId, request.Quantity, ct);
if (!stock.IsAvailable)
return Result<Order>.Failure(
$"Insufficient stock: {stock.Available} available, {request.Quantity} requested",
"INSUFFICIENT_STOCK");
// Create order
var order = await _repository.CreateAsync(request.ToEntity(), ct);
return Result<Order>.Success(order);
}
// Usage in controller/endpoint
app.MapPost("/orders", async (
CreateOrderRequest request,
IOrderService orderService,
CancellationToken ct) =>
{
var result = await orderService.CreateOrderAsync(request, ct);
return result.IsSuccess
? Results.Created($"/orders/{result.Value!.Id}", result.Value)
: Results.BadRequest(new { error = result.Error, code = result.ErrorCode });
});
```
## Data Access Patterns
### Entity Framework Core
```csharp
// DbContext configuration
public class AppDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply all configurations from assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
// Global query filters
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
}
}
// Entity configuration
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("Products");
builder.HasKey(p => p.Id);
builder.Property(p => p.Id).HasMaxLength(40);
builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
builder.Property(p => p.Price).HasPrecision(18, 2);
builder.HasIndex(p => p.Sku).IsUnique();
builder.HasIndex(p => new { p.CategoryId, p.Name });
builder.HasMany(p => p.OrderItems)
.WithOne(oi => oi.Product)
.HasForeignKey(oi => oi.ProductId);
}
}
// Repository with EF Core
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
{
return await _context.Products
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == id, ct);
}
public async Task<IReadOnlyList<Product>> SearchAsync(
ProductSearchCriteria criteria,
CancellationToken ct = default)
{
var query = _context.Products.AsNoTracking();
if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
query = query.Where(p => EF.Functions.Like(p.Name, $"%{criteria.SearchTerm}%"));
if (criteria.CategoryId.HasValue)
query = query.Where(p => p.CategoryId == criteria.CategoryId);
if (criteria.MinPrice.HasValue)
query = query.Where(p => p.Price >= criteria.MinPrice);
if (criteria.MaxPrice.HasValue)
query = query.Where(p => p.Price <= criteria.MaxPrice);
return await query
.OrderBy(p => p.Name)
.Skip((criteria.Page - 1) * criteria.PageSize)
.Take(criteria.PageSize)
.ToListAsync(ct);
}
}
```
### Dapper for Performance
```csharp
public class DapperProductRepository : IProductRepository
{
private readonly IDbConnection _connection;
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
{
const string sql = """
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt
FROM Products
WHERE Id = @Id AND IsDeleted = 0
""";
return await _connection.QueryFirstOrDefaultAsync<Product>(
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
}
public async Task<IReadOnlyList<Product>> SearchAsync(
ProductSearchCriteria criteria,
CancellationToken ct = default)
{
var sql = new StringBuilder("""
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt
FROM Products
WHERE IsDeleted = 0
""");
var parameters = new DynamicParameters();
if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
{
sql.Append(" AND Name LIKE @SearchTerm");
parameters.Add("SearchTerm", $"%{criteria.SearchTerm}%");
}
if (criteria.CategoryId.HasValue)
{
sql.Append(" AND CategoryId = @CategoryId");
parameters.Add("CategoryId", criteria.CategoryId);
}
if (criteria.MinPrice.HasValue)
{
sql.Append(" AND Price >= @MinPrice");
parameters.Add("MinPrice", criteria.MinPrice);
}
if (criteria.MaxPrice.HasValue)
{
sql.Append(" AND Price <= @MaxPrice");
parameters.Add("MaxPrice", criteria.MaxPrice);
}
sql.Append(" ORDER BY Name OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY");
parameters.Add("Offset", (criteria.Page - 1) * criteria.PageSize);
parameters.Add("PageSize", criteria.PageSize);
var results = await _connection.QueryAsync<Product>(
new CommandDefinition(sql.ToString(), parameters, cancellationToken: ct));
return results.ToList();
}
// Multi-mapping for related data
public async Task<Order?> GetOrderWithItemsAsync(int orderId, CancellationToken ct = default)
{
const string sql = """
SELECT o.*, oi.*, p.*
FROM Orders o
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
LEFT JOIN Products p ON oi.ProductId = p.Id
WHERE o.Id = @OrderId
""";
var orderDictionary = new Dictionary<int, Order>();
await _connection.QueryAsync<Order, OrderItem, Product, Order>(
new CommandDefinition(sql, new { OrderId = orderId }, cancellationToken: ct),
(order, item, product) =>
{
if (!orderDictionary.TryGetValue(order.Id, out var existingOrder))
{
existingOrder = order;
existingOrder.Items = new List<OrderItem>();
orderDictionary.Add(order.Id, existingOrder);
}
if (item != null)
{
item.Product = product;
existingOrder.Items.Add(item);
}
return existingOrder;
},
splitOn: "Id,Id");
return orderDictionary.Values.FirstOrDefault();
}
}
```
## Caching Patterns
### Multi-Level Cache with Redis
```csharp
public class CachedProductService : IProductService
{
private readonly IProductRepository _repository;
private readonly IMemoryCache _memoryCache;
private readonly IDistributedCache _distributedCache;
private readonly ILogger<CachedProductService> _logger;
private static readonly TimeSpan MemoryCacheDuration = TimeSpan.FromMinutes(1);
private static readonly TimeSpan DistributedCacheDuration = TimeSpan.FromMinutes(15);
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
{
var cacheKey = $"product:{id}";
// L1: Memory cache (in-process, fastest)
if (_memoryCache.TryGetValue(cacheKey, out Product? cached))
{
_logger.LogDebug("L1 cache hit for {CacheKey}", cacheKey);
return cached;
}
// L2: Distributed cache (Redis)
var distributed = await _distributedCache.GetStringAsync(cacheKey, ct);
if (distributed != null)
{
_logger.LogDebug("L2 cache hit for {CacheKey}", cacheKey);
var product = JsonSerializer.Deserialize<Product>(distributed);
// Populate L1
_memoryCache.Set(cacheKey, product, MemoryCacheDuration);
return product;
}
// L3: Database
_logger.LogDebug("Cache miss for {CacheKey}, fetching from database", cacheKey);
var fromDb = await _repository.GetByIdAsync(id, ct);
if (fromDb != null)
{
var serialized = JsonSerializer.Serialize(fromDb);
// Populate both caches
await _distributedCache.SetStringAsync(
cacheKey,
serialized,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = DistributedCacheDuration
},
ct);
_memoryCache.Set(cacheKey, fromDb, MemoryCacheDuration);
}
return fromDb;
}
public async Task InvalidateAsync(string id, CancellationToken ct = default)
{
var cacheKey = $"product:{id}";
_memoryCache.Remove(cacheKey);
await _distributedCache.RemoveAsync(cacheKey, ct);
_logger.LogInformation("Invalidated cache for {CacheKey}", cacheKey);
}
}
// Stale-while-revalidate pattern
public class StaleWhileRevalidateCache<T>
{
private readonly IDistributedCache _cache;
private readonly TimeSpan _freshDuration;
private readonly TimeSpan _staleDuration;
public async Task<T?> GetOrCreateAsync(
string key,
Func<CancellationToken, Task<T>> factory,
CancellationToken ct = default)
{
var cached = await _cache.GetStringAsync(key, ct);
if (cached != null)
{
var entry = JsonSerializer.Deserialize<CacheEntry<T>>(cached)!;
if (entry.IsStale && !entry.IsExpired)
{
// Return stale data immediately, refresh in background
_ = Task.Run(async () =>
{
var fresh = await factory(CancellationToken.None);
await SetAsync(key, fresh, CancellationToken.None);
});
}
if (!entry.IsExpired)
return entry.Value;
}
// Cache miss or expired
var value = await factory(ct);
await SetAsync(key, value, ct);
return value;
}
private record CacheEntry<TValue>(TValue Value, DateTime CreatedAt)
{
public bool IsStale => DateTime.UtcNow - CreatedAt > _freshDuration;
public bool IsExpired => DateTime.UtcNow - CreatedAt > _staleDuration;
}
}
```
## Testing Patterns
### Unit Tests with xUnit and Moq
```csharp
public class OrderServiceTests
{
private readonly Mock<IOrderRepository> _mockRepository;
private readonly Mock<IStockService> _mockStockService;
private readonly Mock<IValidator<CreateOrderRequest>> _mockValidator;
private readonly OrderService _sut; // System Under Test
public OrderServiceTests()
{
_mockRepository = new Mock<IOrderRepository>();
_mockStockService = new Mock<IStockService>();
_mockValidator = new Mock<IValidator<CreateOrderRequest>>();
// Default: validation passes
_mockValidator
.Setup(v => v.ValidateAsync(It.IsAny<CreateOrderRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ValidationResult());
_sut = new OrderService(
_mockRepository.Object,
_mockStockService.Object,
_mockValidator.Object);
}
[Fact]
public async Task CreateOrderAsync_WithValidRequest_ReturnsSuccess()
{
// Arrange
var request = new CreateOrderRequest
{
ProductId = "PROD-001",
Quantity = 5,
CustomerOrderCode = "ORD-2024-001"
};
_mockStockService
.Setup(s => s.CheckAsync("PROD-001", 5, It.IsAny<CancellationToken>()))
.ReturnsAsync(new StockResult { IsAvailable = true, Available = 10 });
_mockRepository
.Setup(r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Order { Id = 1, CustomerOrderCode = "ORD-2024-001" });
// Act
var result = await _sut.CreateOrderAsync(request);
// Assert
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.Equal(1, result.Value.Id);
_mockRepository.Verify(
r => r.CreateAsync(It.Is<Order>(o => o.CustomerOrderCode == "ORD-2024-001"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task CreateOrderAsync_WithInsufficientStock_ReturnsFailure()
{
// Arrange
var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = 100 };
_mockStockService
.Setup(s => s.CheckAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new StockResult { IsAvailable = false, Available = 5 });
// Act
var result = await _sut.CreateOrderAsync(request);
// Assert
Assert.False(result.IsSuccess);
Assert.Equal("INSUFFICIENT_STOCK", result.ErrorCode);
Assert.Contains("5 available", result.Error);
_mockRepository.Verify(
r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-100)]
public async Task CreateOrderAsync_WithInvalidQuantity_ReturnsValidationError(int quantity)
{
// Arrange
var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = quantity };
_mockValidator
.Setup(v => v.ValidateAsync(request, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ValidationResult(new[]
{
new ValidationFailure("Quantity", "Quantity must be greater than 0")
}));
// Act
var result = await _sut.CreateOrderAsync(request);
// Assert
Assert.False(result.IsSuccess);
Assert.Equal("VALIDATION_ERROR", result.ErrorCode);
}
}
```
### Integration Tests with WebApplicationFactory
```csharp
public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public ProductsApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace real database with in-memory
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
// Replace Redis with memory cache
services.RemoveAll<IDistributedCache>();
services.AddDistributedMemoryCache();
});
});
_client = _factory.CreateClient();
}
[Fact]
public async Task GetProduct_WithValidId_ReturnsProduct()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Products.Add(new Product
{
Id = "TEST-001",
Name = "Test Product",
Price = 99.99m
});
await context.SaveChangesAsync();
// Act
var response = await _client.GetAsync("/api/products/TEST-001");
// Assert
response.EnsureSuccessStatusCode();
var product = await response.Content.ReadFromJsonAsync<Product>();
Assert.Equal("Test Product", product!.Name);
}
[Fact]
public async Task GetProduct_WithInvalidId_Returns404()
{
// Act
var response = await _client.GetAsync("/api/products/NONEXISTENT");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
```
## Best Practices
### DO
1. **Use async/await** all the way through the call stack
2. **Inject dependencies** through constructor injection
3. **Use IOptions<T>** for typed configuration
4. **Return Result types** instead of throwing exceptions for business logic
5. **Use CancellationToken** in all async methods
6. **Prefer Dapper** for read-heavy, performance-critical queries
7. **Use EF Core** for complex domain models with change tracking
8. **Cache aggressively** with proper invalidation strategies
9. **Write unit tests** for business logic, integration tests for APIs
10. **Use record types** for DTOs and immutable data
### DON'T
1. **Don't block on async** with `.Result` or `.Wait()`
2. **Don't use async void** except for event handlers
3. **Don't catch generic Exception** without re-throwing or logging
4. **Don't hardcode** configuration values
5. **Don't expose EF entities** directly in APIs (use DTOs)
6. **Don't forget** `AsNoTracking()` for read-only queries
7. **Don't ignore** CancellationToken parameters
8. **Don't create** `new HttpClient()` manually (use IHttpClientFactory)
9. **Don't mix** sync and async code unnecessarily
10. **Don't skip** validation at API boundaries
## Common Pitfalls
- **N+1 Queries**: Use `.Include()` or explicit joins
- **Memory Leaks**: Dispose IDisposable resources, use `using`
- **Deadlocks**: Don't mix sync and async, use ConfigureAwait(false) in libraries
- **Over-fetching**: Select only needed columns, use projections
- **Missing Indexes**: Check query plans, add indexes for common filters
- **Timeout Issues**: Configure appropriate timeouts for HTTP clients
- **Cache Stampede**: Use distributed locks for cache population
## Resources
- **assets/service-template.cs.template**: Complete service implementation template
- **assets/repository-template.cs.template**: Repository pattern implementation
- **references/ef-core-best-practices.md**: EF Core optimization guide
- **references/dapper-patterns.md**: Advanced Dapper usage patterns
+13
View File
@@ -0,0 +1,13 @@
---
name: nexus-git-workflow
description: Guidelines and standards for Git workflow, commits, and PRs in NexusReader
---
# NexusReader Git Workflow Standards
When working with Git and remote repositories for NexusReader, adhere to the following standards:
- **System Prompts in Tasks**: Tasks in the tracker *usually include* a system prompt that you should use directly for implementation.
- **Pull Request Traceability**: When you create a pull request, it must include a reference to at least one task from the tracker (e.g., "Fixes #123" or "Resolves #456").
- **Gitea MCP Server**: Use the **Gitea MCP server** whenever possible when exchanging data with a remote repository.
- **Atomic Commits**: Create *atomic commits* that represent a single logical change. This makes reviewing, reverting, and bisecting easier.
- **Addressing Comments**: When addressing comments on a pull request, always refer to specific comments and try to resolve them within the conversation.
@@ -8,4 +8,7 @@ description: D3.js standards for Knowledge Graph
- **JS Interop:** Use ES6 modules and `IJSObjectReference`. - **JS Interop:** Use ES6 modules and `IJSObjectReference`.
- **Responsiveness:** SVG must use `viewBox` for fluid portrait scaling. - **Responsiveness:** SVG must use `viewBox` for fluid portrait scaling.
- **Visuals:** Use CSS variables (`--nexus-neon`) for node styling. - **Visuals:** Use CSS variables (`--nexus-neon`) for node styling.
- **Transitions:** Enforce smooth 500ms transitions using the D3.js General Update Pattern (`.join()`).
- **Animations:** Implement "Neon Flash" entry animations for newly discovered knowledge nodes.
- **Contextual Highlight:** Support node/link dimming to emphasize the current reading context.
- **Events:** JS emits events (like `nodeClicked`) caught by Blazor via `DotNetObjectReference`. - **Events:** JS emits events (like `nodeClicked`) caught by Blazor via `DotNetObjectReference`.
@@ -22,7 +22,7 @@ description: Design System & Component rules for Blazor
- Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`). - Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`).
- **Typography:** - **Typography:**
- UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels. - UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels.
- Reading Content: `Merriweather` (Serif) for books and articles to ensure high readability. - Reading Content: `Merriweather` (Serif) with `line-height: 1.65` and `letter-spacing: -0.01em` for high readability.
- **Effects:** - **Effects:**
- Subtle neon glows (`box-shadow: 0 0 15px rgba(0, 255, 153, 0.3)`). - Subtle neon glows (`box-shadow: 0 0 15px rgba(0, 255, 153, 0.3)`).
- Glassmorphism for overlays and modals. - Glassmorphism for overlays and modals.
@@ -30,6 +30,11 @@ description: Design System & Component rules for Blazor
- **Adaptive Layouts:** - **Adaptive Layouts:**
- Support `.platform-mobile` and `.platform-desktop` context classes. - Support `.platform-mobile` and `.platform-desktop` context classes.
- Handle safe-area insets (`--safe-area-inset-*`) for mobile devices. - Handle safe-area insets (`--safe-area-inset-*`) for mobile devices.
- **Immersive Reader (Zen Mode):**
- Centered content flow: `max-width: 800px`, `margin: 0 auto`.
- Paper-white background: `#F9F9F9` for light mode reader canvas.
- Dedicated Scrollbars: Custom styled, thin scrollbars with `--nexus-neon` accents.
- Reachability: Large `padding-bottom` (e.g., `15rem`) to ensure comfortable reading of end-of-page content.
- **Accessibility (A11y):** - **Accessibility (A11y):**
- Touch Targets: Min `44x44px` on mobile (enforced via CSS variables). - Touch Targets: Min `44x44px` on mobile (enforced via CSS variables).
@@ -39,3 +44,6 @@ description: Design System & Component rules for Blazor
- **Interactive Flow:** - **Interactive Flow:**
- AI Assistant interactions must be non-blocking and smoothly transition using CSS animations. - AI Assistant interactions must be non-blocking and smoothly transition using CSS animations.
- Interactive elements must have clear `:hover`, `:active`, and `:focus` states. - Interactive elements must have clear `:hover`, `:active`, and `:focus` states.
- **Glass Panel Standard:** All primary data panels (`.glass-panel`) must implement the following interaction signature:
- `transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)`
- `:hover` state must include: `transform: translateY(-4px)`, increased background opacity, and a subtle `--nexus-neon` border highlight (e.g., `rgba(0, 255, 153, 0.2)`).
-11
View File
@@ -1,11 +0,0 @@
# Definition of Done (DoD)
1. **Architecture Compliance:** Feature follows CQRS flow. Logic is in Handlers. Result is wrapped in `Result<T>` from FluentResult.
2. **Modularization:** Code is in `/src`, tests in `/tests`. Module-specific logic is isolated.
3. **UI/UX Integrity:** - "Vertical Flow Check" passed (Assistant is part of the document stream, not an absolute pop-up).
- No "Layout Shift" during AI content streaming.
- Safe-area-insets respected for iOS/Android notches.
4. **Code Quality:** C# 14 syntax used (Primary Constructors, etc.). Scoped CSS (.razor.css) implemented.
5. **D3.js Performance:** JS Modules correctly disposed using `IAsyncDisposable`.
6. **Persistence:** State survives manual page refresh (Local/Session Storage integration).
7. **Mapping:** All entity-to-DTO conversions must use Mapster.
-16
View File
@@ -1,16 +0,0 @@
# Agent Personas
## NexusArchitect
- **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor)
- **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI".
- **Architecture Role:** Lead Clean Architecture Specialist.
- **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator, nexus-identity-saas, dotnet-async-void]
- **Technical Constraints:**
- **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level.
- **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components.
- **Async:** Strict zero-tolerance for `async void`. All async operations must return `Task` or `ValueTask`. Event handlers must use `Func<Task>` or async-compatible patterns.
- **Error Handling:** All handlers must return `Result<T>` via `FluentResult`.
- **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper.
- **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance.
- **Verification:** Follow "Verification-led development" — the agent must plan the test before writing the feature code.
- **UI Framework:** Use Blazor Component Model. NEVER generate raw HTML/CSS; always use isolated Razor Components (.razor + .razor.css).
+3 -1
View File
@@ -29,4 +29,6 @@ Thumbs.db
*.epub *.epub
.fake .fake
src/NexusReader.Web.New/nexus.db src/NexusReader.Web/nexus.db
src/NexusReader.Web/wwwroot/covers/
src/NexusReader.Web/wwwroot/uploads/
-87
View File
@@ -1,87 +0,0 @@
# 📑 Project Backlog: Nexus AI E-Reader (Mockup Implementation)
**Architecture Framework:** .NET 10 | Blazor Component Model | CQRS with MediatR | FluentResult | Mapster
---
## 🟢 PHASE 1: Infrastructure & Design Tokens
*Goal: Prepare the clean architecture foundation and the visual DNA.*
### [TASK-01] Solution & Directory Structure Setup
- **Action:** Create the folder structure: `/src` for projects, `/tests` for unit/integration tests.
- **Details:** Initialize `NexusReader.Web` (Blazor), `NexusReader.Application`, `NexusReader.Domain`, and `NexusReader.Infrastructure`.
- **DoD:** Solution compiles with strict folder separation.
### [TASK-02] Core Library Integration
- **Action:** Install and configure LuckyPennySoftware.MediatR, Mapster, and FluentResult.
- **Details:** - Setup `MappingConfig` for Mapster in the Application layer.
- Implement a `BaseHandler` that returns `Result<T>`.
- **DoD:** A sample Query returns a `Success` Result via MediatR.
### [TASK-03] Nexus Neon Design System
- **Action:** Implement global CSS variables in `app.css` and base Atoms.
- **Details:** - Variables: `--nexus-neon: #00ff99`, `--nexus-bg: #121212`, `--nexus-card: #1e1e1e`.
- Components: `NexusButton.razor`, `NexusTypography.razor` (handling Serif for ebook, Sans for UI).
- **DoD:** Variables are accessible via all scoped CSS files.
---
## 🔵 PHASE 2: Seamless Reader & AI Assistant (Left Side / Inline)
*Goal: Implement the ebook reading logic and the "Vertical Flow" AI injection.*
### [TASK-04] ReaderCanvas & Dynamic Content Injection
- **Action:** Create `ReaderCanvas.razor` to render ebook text.
- **Details:** - Logic to split text into blocks.
- Implementation of an "Injection Point" system where `AiAssistantBubble.razor` can be rendered inline between paragraphs.
- **Mockup Match:** Text must use the high-contrast Serif font from the mockup.
### [TASK-05] AiAssistantBubble Component
- **Action:** Implement the AI chat bubble with a robot avatar.
- **Details:** - Scoped CSS for the glowing border and dark glassmorphism background.
- Parameters for `DialogueText` and `ActionButtons` ("Pokaż więcej", "Rozwiąż quiz").
- Integration with Semantic Kernel for streaming text.
- **DoD:** Bubble appears smoothly in the text flow without absolute positioning.
---
## 🟡 PHASE 3: Knowledge Graph & Brain (Right Side / Flow)
*Goal: Implement the D3.js graph and the "You are here" logic.*
### [TASK-06] D3.js Knowledge Graph Bridge
- **Action:** Implement `KnowledgeGraph.razor` with JS Interop.
- **Details:** - ES6 Module `knowledgeGraph.js` using D3.js v7.
- SVG ViewBox scaling for portrait orientation.
- Implementation of the "TU JESTEŚ" (You Are Here) pulsing label on the active node.
- **DoD:** Clicking a node in JS triggers a C# EventCallback via `DotNetObjectReference`.
### [TASK-07] Semantic Mapping Service
- **Action:** Create the `GetKnowledgeGraphQuery` (CQRS).
- **Details:** - Service uses Semantic Kernel to extract nodes from the current chapter.
- Mapster maps the AI raw response to the `GraphViewModel`.
- **DoD:** Graph updates dynamically when the reader moves to a new chapter.
---
## 🟠 PHASE 4: Verification & Mobile Polish
*Goal: Implement the quiz module and cross-platform readiness.*
### [TASK-08] KnowledgeCheck (Quiz) Module
- **Action:** Implement the `SubmitAnswerCommand` using MediatR.
- **Details:** - UI: `KnowledgeCheck.razor` with radio buttons and a "Wyślij" (Submit) button.
- Logic: Handler returns `Result` (Success/Failure) via FluentResult.
- Mapster: Map `QuizDto` to `QuizViewModel`.
- **Mockup Match:** Neon highlight on the selected/correct answer.
### [TASK-09] Persistence & Cross-Platform (Hybrid)
- **Action:** Implement `IPlatformService` for Android/iOS support.
- **Details:** - Safe-area-insets implementation for notches.
- `BrowserStorage` implementation of `AppState` to save progress.
- Haptic feedback abstraction (trigger vibration on correct answer).
- **DoD:** App maintains graph state after a manual refresh.
---
## 🛠️ Instructions for NexusArchitect
1. **Vertical Flow Priority:** Ensure that the AI assistant and the Graph never overlay text. Use `Flexbox` or `Grid` for a single, continuous scrollable column in portrait mode.
2. **Result Pattern:** Every single Mediator Handler **must** return `FluentResults.Result`.
3. **Mapster:** Perform all DTO-to-UI mappings in the Query/Command Handlers, not in the Razor components.
4. **Isolated Styles:** All specific CSS for the Neon effect must be in `.razor.css` files.
+55
View File
@@ -0,0 +1,55 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="Mapster" Version="10.0.7" />
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="MediatR" Version="12.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.0" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
<PackageVersion Include="Pgvector" Version="0.3.0" />
<PackageVersion Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
<PackageVersion Include="Microsoft.Extensions.Resilience" Version="10.5.0" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
<PackageVersion Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.23" />
<PackageVersion Include="Hangfire.PostgreSql" Version="1.21.1" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
<PackageVersion Include="Microsoft.ML.Tokenizers.Data.Cl100kBase" Version="2.0.0" />
<PackageVersion Include="Neo4j.Driver" Version="6.1.1" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageVersion Include="Polly" Version="8.6.6" />
<PackageVersion Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageVersion Include="Qdrant.Client" Version="1.18.1" />
<PackageVersion Include="Stripe.net" Version="51.1.0" />
<PackageVersion Include="VersOne.Epub" Version="3.3.6" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="9.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.20" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.20" />
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageVersion Include="xunit" Version="2.9.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="Moq" Version="4.20.70" />
</ItemGroup>
</Project>
+3 -3
View File
@@ -3,20 +3,20 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
# Copy csproj files and restore dependencies # Copy csproj files and restore dependencies
COPY ["src/NexusReader.Web.New/NexusReader.Web.csproj", "src/NexusReader.Web.New/"] COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"]
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"] COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"] COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"] COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"] COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"] COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
RUN dotnet restore "src/NexusReader.Web.New/NexusReader.Web.csproj" RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
# Copy the rest of the source code # Copy the rest of the source code
COPY . . COPY . .
# Build and publish # Build and publish
WORKDIR "/src/src/NexusReader.Web.New" WORKDIR "/src/src/NexusReader.Web"
RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Stage 2: Runtime # Stage 2: Runtime
+49
View File
@@ -0,0 +1,49 @@
---
type: agent-definitions
version: 1.0
---
# Agent Personas
## 👤 NexusArchitect
**Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor)
**Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI".
---
## 🏗️ Architecture Philosophy
- **Clean Architecture:** Strict separation of concerns. `Domain` -> `Application` <- `Infrastructure`.
- **CQRS Pattern:** Mandatory use of `MediatR`. Logic belongs in handlers, not UI components.
- **Result Pattern:** Zero exceptions for flow control. All handlers return `Result<T>` via `FluentResult`.
- **Mapping:** Exclusive use of `Mapster`. (Zero tolerance for AutoMapper).
---
## 🛠️ Technical Constraints
>
> [!IMPORTANT]
> **Zero Tolerance for `async void`**
> All async operations must return `Task` or `ValueTask`. Event handlers must use `Func<Task>` or async-compatible patterns.
- **Platform:** Target .NET 10 with **Native AOT** compatibility. Optimize for mobile performance.
- **UI Framework:** Blazor Component Model. No raw HTML/CSS; use isolated Razor Components (.razor + .razor.css).
- **Directory Structure:** `/src` for app code and `/tests` for testing code at solution root level.
---
## 🧪 Development Workflow
1. **Verification-Led:** Plan and define tests/verification steps *before* writing feature code.
2. **Step-by-Step Execution:** Break complex tasks into manageable, verifiable chunks.
3. **Layer Integrity:** Always check for illegal cross-layer dependencies (e.g., Application depending on Infrastructure).
4. **Mandatory Build Gate:** After **every** code change, run `dotnet build` on the full solution. The agent MUST NOT proceed or report completion if there are any `error CS*` compiler errors. All build errors must be resolved before moving to the next step.
> [!IMPORTANT]
> **Build command:** `dotnet build NexusReader.slnx --no-restore`
> Run from the solution root `/home/mjasin/Projekty/ejajBook`. Build warnings are acceptable; errors are not.
> [!IMPORTANT]
> **Git Workflow & Integration**
> All tasks originating from the repository must be performed on a separate branch. To connect to the Git repository, use the `gitea` MCP server.
+6 -2
View File
@@ -6,9 +6,13 @@
<Project Path="src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj" /> <Project Path="src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj" />
<Project Path="src/NexusReader.Web.Client/NexusReader.Web.Client.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.UI.Shared/NexusReader.UI.Shared.csproj" />
<Project Path="src/NexusReader.Data/NexusReader.Data.csproj" />
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" /> <Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
</Folder> </Folder>
<Folder Name="/src/NexusReader.Web.New/"> <Folder Name="/src/NexusReader.Web/">
<Project Path="src/NexusReader.Web.New/NexusReader.Web.csproj" /> <Project Path="src/NexusReader.Web/NexusReader.Web.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>
+38
View File
@@ -0,0 +1,38 @@
# 📖 Nexus Reader
Nexus Reader is a state-of-the-art, cross-platform Blazor .NET 10 immersive e-book reader, powered by **Native AOT**, **Clean Architecture**, **CQRS**, and interactive **D3.js Relationship Graphs** built on vector-based AI semantics.
---
## ✨ Features & Architecture Highlights
### 📁 Ingestion & Description persistence
- Extracted and persistent **book descriptions** from EPUB package metadata during book ingestion.
- The `Description` field propagates cleanly from the `Ebook` entity through Mapster to `LastReadBookDto` and `UserProfileDto`.
### 🔗 Deep-Link Routing
- Implemented deep-link route activation: `/reader/{bookId}?chapter=N`.
- Allows instant resume of reading session coordinates and loads the specific chapter chapter directly via URL query parameters.
### 🛡️ Downstream AI Resilience
- Standard resilience engine in `DependencyInjection.cs` utilizing the **Polly** package (`ai-retry`).
- Automatically intercepts, handles, and retries on both rate-limits (`429 Too Many Requests`) and downstream capacity overloads (`503 ServiceUnavailable` / `high demand`).
### ⚙️ Concurrent Request Deduplication
- Multi-client InteractiveAuto Blazor circuit synchronization is backed by a thread-safe active task registry in `KnowledgeService` which ensures that identical concurrent requests await a single shared task instance, eliminating redundant LLM queries.
---
## 🛠️ Build & Verification Gate
Ensure the dotnet workload matches the active SDK, and compile the full solution utilizing:
```bash
dotnet build NexusReader.slnx --no-restore
```
Run test suite:
```bash
dotnet test --no-restore
```
-87
View File
@@ -1,87 +0,0 @@
# 🤖 LLM Agent Implementation Backlog: AI Semantic Integration
**Project Context:** .NET 10, EF Core (SQLite), `Microsoft.Extensions.AI`, [`GeminiDotnet`](https://github.com/rabuckley/GeminiDotnet).
**Core Goal:** Integrate Gemini 1.5 Flash with a persistent Semantic Cache to minimize API costs and latency.
---
## 🏗️ Phase 1: Persistence & Domain Layer
**Objective:** Define the storage schema to prevent redundant AI calls.
### Task 1.1: Create `SemanticKnowledgeCache` Entity
* **Target Folder:** `Core/Entities` or `Infrastructure/Persistence/Entities`.
* **Requirements:**
* Create a class `SemanticKnowledgeCache`.
* **Properties:**
* `string ContentHash` (Key, Fixed length 64).
* `string JsonData` (Required, stores the serialized AI output).
* `string ModelId` (Default: "gemini-1.5-flash").
* `string PromptVersion` (Default: "1.0").
* `DateTime CreatedAt` (UTC).
* **LLM Instructions:** "Generate an EF Core entity for SemanticKnowledgeCache. Ensure `ContentHash` has a Unique Index for O(1) lookups."
### Task 1.2: Implement Hashing Utility
* **Target Folder:** `Core/Helpers` or `Infrastructure/Security`.
* **Requirements:**
* Create `ContentHasher` class.
* Method `string ComputeHash(string input)`.
* **Logic:** Normalize input (Trim, lower-case) -> Compute SHA-256 -> Return Hex string.
* **LLM Instructions:** "Create a thread-safe utility to generate SHA-256 hashes from strings. Ensure it handles nulls and whitespace consistently."
---
## 🧠 Phase 2: AI Client & Contract Definition
**Objective:** Set up the communication bridge with Google Gemini API using [`GeminiDotnet`](https://github.com/rabuckley/GeminiDotnet).
### Task 2.1: Define Data Transfer Objects (DTOs)
* **Target Folder:** `NexusReader.Application/DTOs/AI`.
* **Requirements:**
* Define `KnowledgePacket` record containing `List<KeyConcept>` and `List<QuizQuestion>`.
* Use `[JsonPropertyName]` attributes for strict JSON mapping.
* **LLM Instructions:** "Define immutable records for the AI response schema. Ensure they match the expected JSON structure from the system prompt."
### Task 2.2: Infrastructure AI Client Setup
* **Target:** `NexusReader.Infrastructure/DependencyInjection.cs`.
* **Requirements:**
* Install `Microsoft.Extensions.AI` and `GeminiDotnet.Extensions.AI`.
* Register `IChatClient` using `GeminiChatClient`.
* Inject `ApiKey` from `IConfiguration`.
* **LLM Instructions:** "Register the `GeminiChatClient` in the DI container. Use the .NET 10 `AddChatClient` extension pattern."
---
## ⚙️ Phase 3: Service Orchestration (The "Smart" Logic)
**Objective:** Implement the caching proxy logic.
### Task 3.1: Create `KnowledgeService` Implementation
* **Target Folder:** `Application/Services`.
* **Logic Flow:**
1. `hash = ContentHasher.ComputeHash(inputText)`.
2. `cached = await dbContext.Cache.FirstOrDefaultAsync(h => h.ContentHash == hash)`.
3. If `cached` exists AND `PromptVersion` matches -> Deserialize and return.
4. Else -> Call `IChatClient.CompleteAsync<KnowledgePacket>(...)`.
5. Save result to DB with the hash -> Return.
* **LLM Instructions:** "Implement a service that acts as a proxy between the UI and the Gemini API. It must prioritize SQLite cache hits over API calls."
### Task 3.2: System Prompt Engineering
* **Requirements:**
* Create a `PromptRegistry` class.
* **System Message:** "You are an educational assistant. Analyze the text and output ONLY valid minified JSON. Schema: { 'concepts': [], 'quizzes': [] }. Do not include markdown formatting like \` \` \` json."
* **LLM Instructions:** "Craft a high-precision system prompt for Gemini 1.5 Flash to ensure it returns parseable JSON without unnecessary tokens."
---
## 🛡️ Phase 4: Resilience & Optimization
**Objective:** Handle API limits and monitor performance.
### Task 4.1: Resilience Pipeline (Polly)
* **Requirements:**
* Implement an `HttpRetry` policy specifically for `429 Too Many Requests`.
* Use Exponential Backoff with Jitter.
* **LLM Instructions:** "Add a resilience pipeline to the AI client using Polly. Handle rate-limiting gracefully to stay within the Gemini Free Tier limits."
### Task 4.2: Request Pre-processing (Token Saving)
* **Logic:**
* Check input string length.
* If `length > threshold`, truncate or throw an error to prevent massive token spend.
* **LLM Instructions:** "Add a guard clause to the KnowledgeService to validate input size before calling the API. Log the estimated token count."
-46
View File
@@ -1,46 +0,0 @@
# NexusArchitect - User Management Implementation Backlog
**Project:** AI-Powered E-book Reader SaaS
**Architecture:** .NET 10, Blazor Hybrid, MAUI, ASP.NET Core Identity
**Primary Goal:** Implement a secure, scalable authentication and authorization system with SaaS-specific features (AI token limits, subscription tiers).
---
## Phase 0: Backend Foundations (ASP.NET Core & EF Core)
| ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|:---|:---|:---|:---|
| **BACK-1** | Define Extended `NexusUser` Model | **Description:** Create a `NexusUser` class inheriting from `IdentityUser`. Add custom properties for SaaS logic.<br>**AC:**<br>- [x] Properties added: `AITokenLimit` (int), `AITokensUsed` (int), `TenantId` (Guid), `CurrentPlan` (string).<br>- [x] Model placed in `NexusArchitect.Core` project. | C# / Identity |
| **BACK-2** | Configure `ApplicationDbContext` for Identity | **Description:** Set up the DB context to inherit from `IdentityDbContext<NexusUser>`.<br>**AC:**<br>- [x] Mapped standard Identity tables (Users, Roles, Claims).<br>- [x] Configured 1-to-Many relationship between `NexusUser` and `Ebooks`. | EF Core |
| **BACK-3** | Database Schema Migration | **Description:** Generate and apply the initial migration for Identity tables.<br>**AC:**<br>- [x] SQL schema contains all 7+ standard Identity tables.<br>- [x] Custom `NexusUser` fields are correctly reflected in the `AspNetUsers` table. | EF Core CLI |
| **BACK-4** | Implement Identity API Endpoints | **Description:** Enable native .NET Identity API endpoints in `Program.cs`.<br>**AC:**<br>- [x] Endpoints `/register`, `/login`, and `/refresh` are active.<br>- [x] Verified functionality via Swagger/OpenAPI. | ASP.NET Core |
---
## Phase 1: Authentication & Authorization (UI & Logic)
| ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|:---|:---|:---|:---|
| **BACK-5** | Define Authorization Policies | **Description:** Implement Roles and Claims-based authorization (Free vs. Pro).<br>**AC:**<br>- [x] Created a `ProUser` policy.<br>- [x] Implemented a custom `Requirement` to check if `AITokensUsed < AITokenLimit`. | ASP.NET Core |
| **UI-1** | Implement Login Page (Blazor) | **Description:** Build the Login UI based on the Dark Mode mockup.<br>**AC:**<br>- [x] Theme: Dark mode with neon green accents.<br>- [x] Components: Email/Password fields, "Remember Me" toggle, "Login" button.<br>- [x] Integrates with `AuthenticationStateProvider`. | Blazor / CSS |
| **UI-2** | Google OAuth2 Integration | **Description:** Configure external login provider (Google) in the backend and UI.<br>**AC:**<br>- [x] Users can sign in via Google button.<br>- [x] New users are automatically provisioned in the database upon successful OAuth. | OAuth / Google Cloud |
| **UI-3** | Implement Registration Flow | **Description:** Create a registration form calling the `/register` endpoint.<br>**AC:**<br>- [x] Validation: Email format, password complexity (min 8 chars, uppercase, digit).<br>- [x] Proper error handling for existing users. | Blazor |
---
## Phase 2: User Management & SaaS Scaling (Profile & Mobile)
| ID | Task Title | Description & Acceptance Criteria | Tech Stack |
|:---|:---|:---|:---|
| **UI-4** | User Profile & Dashboard | **Description:** Build the User Profile UI focusing on "Active Learning" metrics.<br>**AC:**<br>- [x] Displays: Token usage bar (Used/Limit), average quiz score, and last read book.<br>- [x] Links to subscription management. | Blazor |
| **MAUI-1** | Mobile Auth Integration (Blazor Hybrid) | **Description:** Ensure the authentication state is shared and persists in the MAUI container.<br>**AC:**<br>- [x] Securely store JWT tokens in `SecureStorage`.<br>- [x] Automatic login on app launch if token is valid. | MAUI / Blazor Hybrid |
| **MAUI-2** | Secure Session Persistence | **Description:** Implement long-lived session management using encrypted device storage.<br>**AC:**<br>- [x] Refresh tokens implementation for mobile.<br>- [x] "Stay Signed In" functionality. | MAUI / Identity |
| **INTEG-1** | Stripe Subscription Webhooks | **Description:** Sync Identity Claims with Stripe subscription status.<br>**AC:**<br>- [x] Webhook updates `AITokenLimit` when a "Pro" plan is purchased.<br>- [x] User is downgraded back to "Free" limit upon cancellation. | Stripe SDK / .NET |
---
## Definition of Done (DoD)
- All code follows the **NexusArchitect** architectural guidelines.
- Unit tests cover core Identity logic (e.g., token limit validation).
- UI is responsive and consistent with the provided Dark Mode design.
- Documentation updated with setup instructions for new developers.
+39 -1
View File
@@ -1,6 +1,6 @@
services: services:
db: db:
image: postgres:17-alpine image: pgvector/pgvector:pg17
container_name: nexus-db container_name: nexus-db
environment: environment:
POSTGRES_USER: nexus_user POSTGRES_USER: nexus_user
@@ -26,12 +26,50 @@ services:
environment: environment:
- ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__PostgresConnection=Host=db;Database=nexus_db;Username=nexus_user;Password=nexus_password - ConnectionStrings__PostgresConnection=Host=db;Database=nexus_db;Username=nexus_user;Password=nexus_password
- ConnectionStrings__QdrantConnection=Host=qdrant;Port=6334
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder} - Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder} - Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder} - Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
qdrant:
condition: service_healthy
neo4j:
condition: service_healthy
qdrant:
image: qdrant/qdrant:latest
container_name: nexus-qdrant
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant_data:/qdrant/storage
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
interval: 5s
timeout: 5s
retries: 5
neo4j:
image: neo4j:5-community
container_name: nexus-neo4j
ports:
- "7474:7474"
- "7687:7687"
environment:
- NEO4J_AUTH=none
volumes:
- neo4j_data:/data
healthcheck:
test: ["CMD-SHELL", "cypher-shell -u neo4j -p '' 'RETURN 1' || exit 0"]
interval: 5s
timeout: 5s
retries: 5
volumes: volumes:
pgdata: pgdata:
qdrant_data:
neo4j_data:
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ------------------------------------------------------------- # -------------------------------------------------------------
# Debug helper for NexusReader.Web.New (Blazor Server) # Debug helper for NexusReader.Web (Blazor Server)
# ------------------------------------------------------------- # -------------------------------------------------------------
# 1️⃣ Ensure the port is free before starting the server. # 1️⃣ Ensure the port is free before starting the server.
# 2️⃣ Starts the server project in the background. # 2️⃣ Starts the server project in the background.
@@ -10,7 +10,7 @@
# ------------------------------------------------------------- # -------------------------------------------------------------
# ---- configuration ------------------------------------------------ # ---- configuration ------------------------------------------------
SERVER_PROJECT="src/NexusReader.Web.New/NexusReader.Web.csproj" SERVER_PROJECT="src/NexusReader.Web/NexusReader.Web.csproj"
APP_URL="http://localhost:5104" APP_URL="http://localhost:5104"
DEBUG_PORT=9222 DEBUG_PORT=9222
TMP_PROFILE="/tmp/blazor-chrome-debug" TMP_PROFILE="/tmp/blazor-chrome-debug"
-25
View File
@@ -1,25 +0,0 @@
using System;
using System.Text.RegularExpressions;
public class Program
{
public static void Main()
{
string input1 = "Hello \n World";
string input2 = "Hello World";
string norm1 = Normalize(input1);
string norm2 = Normalize(input2);
Console.WriteLine($"Input 1: '{input1}' -> Normalized: '{norm1}'");
Console.WriteLine($"Input 2: '{input2}' -> Normalized: '{norm2}'");
Console.WriteLine($"Match: {norm1 == norm2}");
}
public static string Normalize(string input)
{
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
var normalized = Regex.Replace(input.Trim(), @"\s+", " ");
return normalized.ToLowerInvariant();
}
}
@@ -0,0 +1,38 @@
namespace NexusReader.Application.Abstractions.Messaging;
/// <summary>
/// Abstraction for broadcasting real-time sync events to connected clients.
/// Defined in Application to prevent a direct dependency on SignalR in Application layer handlers.
/// </summary>
public interface ISyncBroadcaster
{
/// <summary>
/// Broadcasts a reading progress update to all devices belonging to the specified user,
/// optionally excluding the originating connection.
/// </summary>
/// <param name="userId">The user whose other devices should be notified.</param>
/// <param name="pageId">The block/page ID the user has reached.</param>
/// <param name="timestamp">The server-side UTC timestamp of the update.</param>
/// <param name="excludedConnectionId">SignalR connection ID to exclude (the sender's device).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task BroadcastProgressAsync(
string userId,
string pageId,
DateTime timestamp,
string? excludedConnectionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Broadcasts real-time ingestion status updates to a specific user.
/// This is used by background workers to provide feedback during AI-intensive processing.
/// </summary>
/// <param name="userId">The ID of the user who owns the ingestion request.</param>
/// <param name="message">A human-readable status message (e.g., "Parsing chapters...").</param>
/// <param name="progress">Progress percentage (0.0 to 1.0).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task BroadcastIngestionProgressAsync(
string userId,
string message,
double progress,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,30 @@
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Abstractions.Persistence;
/// <summary>
/// Abstraction for Ebook and Author persistence operations.
/// Defined in the Application layer to avoid a direct dependency on EF Core.
/// </summary>
public interface IEbookRepository
{
/// <summary>
/// Finds an author by name using a case-insensitive comparison.
/// </summary>
Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new author to the repository (staged, not yet persisted).
/// </summary>
void AddAuthor(Author author);
/// <summary>
/// Adds a new ebook to the repository (staged, not yet persisted).
/// </summary>
void AddEbook(Ebook ebook);
/// <summary>
/// Persists all staged changes to the underlying store.
/// </summary>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -1,9 +0,0 @@
using FluentResults;
using NexusReader.Application.Queries.Quiz;
namespace NexusReader.Application.Abstractions.Services;
public interface IAiGenerateQuizService
{
Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default);
}
@@ -1,9 +1,10 @@
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using FluentResults;
namespace NexusReader.Application.Abstractions.Services; namespace NexusReader.Application.Abstractions.Services;
public interface IBillingService public interface IBillingService
{ {
Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId); Task<Result> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId);
Task<bool> HandleSubscriptionDeletedAsync(string customerEmail); Task<Result> HandleSubscriptionDeletedAsync(string customerEmail);
} }
@@ -0,0 +1,29 @@
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// Service for managing ebook and cover file storage.
/// </summary>
public interface IBookStorageService
{
/// <summary>
/// Saves an ebook file and returns its relative path/URL.
/// </summary>
Task<string> SaveEbookAsync(byte[] data, string fileName);
/// <summary>
/// Saves an ebook file using a stream and returns its relative path/URL.
/// </summary>
Task<string> SaveEbookAsync(Stream data, string fileName);
/// <summary>
/// Saves a cover image and returns its relative path/URL.
/// Returns null if no cover data is provided.
/// </summary>
Task<string?> SaveCoverAsync(byte[] data, string fileName);
/// <summary>
/// Saves a cover image using a stream and returns its relative path/URL.
/// Returns null if no cover data is provided.
/// </summary>
Task<string?> SaveCoverAsync(Stream data, string fileName);
}
@@ -0,0 +1,17 @@
using FluentResults;
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// Service abstraction to extract raw text content from EPUB chapters.
/// </summary>
public interface IEpubExtractor
{
/// <summary>
/// Extracts the sanitized, plain-text content of each chapter in the EPUB file.
/// </summary>
/// <param name="relativePath">The relative storage path of the EPUB file.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of plain-text chapters, or a failure result.</returns>
Task<Result<List<string>>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,10 @@
using FluentResults;
using NexusReader.Application.Queries.Reader;
using System.IO;
namespace NexusReader.Application.Abstractions.Services;
public interface IEpubMetadataExtractor
{
Task<Result<LocalEpubMetadata>> ExtractMetadataAsync(Stream epubStream);
}
@@ -0,0 +1,23 @@
using FluentResults;
using NexusReader.Application.Queries.Reader;
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// Reads and parses EPUB content for a specific ebook and chapter.
/// </summary>
public interface IEpubReader
{
/// <summary>
/// Retrieves the content blocks for a given chapter of the specified ebook.
/// </summary>
/// <param name="ebookId">The unique ID of the ebook to read.</param>
/// <param name="chapterIndex">Zero-based chapter index.</param>
/// <param name="userId">The authenticated user's ID (used for tenant isolation in the DB lookup).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(
Guid ebookId,
int chapterIndex,
string? userId = null,
CancellationToken cancellationToken = default);
}
@@ -1,9 +0,0 @@
using FluentResults;
using NexusReader.Application.Queries.Reader;
namespace NexusReader.Application.Abstractions.Services;
public interface IEpubService
{
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex);
}
@@ -0,0 +1,15 @@
using FluentResults;
using NexusReader.Application.DTOs.User;
namespace NexusReader.Application.Abstractions.Services;
public interface IIdentityService
{
event Func<Task>? OnStateInvalidated;
Task<Result> RegisterAsync(string email, string password);
Task<Result> LoginAsync(string email, string password, bool rememberMe = false);
Task<Result> LogoutAsync();
Task<Result<UserProfileDto>> GetProfileAsync();
Task<Result> RefreshTokenAsync();
void ClearCache();
}
@@ -5,8 +5,15 @@ namespace NexusReader.Application.Abstractions.Services;
public interface IKnowledgeService public interface IKnowledgeService
{ {
Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default); Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, CancellationToken cancellationToken = default); Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default); Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default);
Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default);
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default); Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
} }
public record GroundednessResult(float Score, string Rationale, bool IsGrounded);
@@ -4,13 +4,13 @@ namespace NexusReader.Application.Abstractions.Services;
public interface INativeStorageService public interface INativeStorageService
{ {
Result SaveString(string key, string value); Task<Result> SaveStringAsync(string key, string value);
Result<string?> GetString(string key); Task<Result<string?>> GetStringAsync(string key);
Result SaveBool(string key, bool value); Task<Result> SaveBoolAsync(string key, bool value);
Result<bool> GetBool(string key, bool defaultValue = false); Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false);
Result Remove(string key); Task<Result> RemoveAsync(string key);
Task<Result> SaveSecureString(string key, string value); Task<Result> SaveSecureString(string key, string value);
Task<Result<string?>> GetSecureString(string key); Task<Result<string?>> GetSecureString(string key);
Result RemoveSecure(string key); Task<Result> RemoveSecureAsync(string key);
} }
@@ -0,0 +1,22 @@
using FluentResults;
using MediatR;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Application.Commands.AI;
public record VerifyGroundednessCommand(string Answer, string Context, string TenantId) : IRequest<Result<GroundednessResult>>;
public class VerifyGroundednessCommandHandler : IRequestHandler<VerifyGroundednessCommand, Result<GroundednessResult>>
{
private readonly IKnowledgeService _knowledgeService;
public VerifyGroundednessCommandHandler(IKnowledgeService knowledgeService)
{
_knowledgeService = knowledgeService;
}
public async Task<Result<GroundednessResult>> Handle(VerifyGroundednessCommand request, CancellationToken cancellationToken)
{
return await _knowledgeService.VerifyGroundednessAsync(request.Answer, request.Context, request.TenantId, cancellationToken);
}
}
@@ -0,0 +1,23 @@
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Commands.Library;
/// <summary>
/// Command to ingest a new ebook into the library.
/// </summary>
/// <param name="Title">The title of the book.</param>
/// <param name="AuthorName">The name of the author.</param>
/// <param name="CoverImage">The raw bytes of the cover image (optional).</param>
/// <param name="EpubData">The raw bytes of the EPUB file.</param>
/// <param name="Description">The description or summary of the book (optional).</param>
/// <param name="UserId">The ID of the user owning the book.</param>
/// <param name="TenantId">The tenant ID for multi-tenant isolation. Defaults to "global" for single-tenant or default usage.</param>
public record IngestEbookCommand(
string Title,
string AuthorName,
byte[]? CoverImage,
byte[] EpubData,
string? Description,
string UserId,
string TenantId = "global"
) : ICommand<Guid>;
@@ -0,0 +1,101 @@
using FluentResults;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Commands.Library;
public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Result<Guid>>
{
private readonly IEbookRepository _ebookRepository;
private readonly IBookStorageService _storageService;
private readonly IServiceScopeFactory _scopeFactory;
public IngestEbookCommandHandler(
IEbookRepository ebookRepository,
IBookStorageService storageService,
IServiceScopeFactory scopeFactory)
{
_ebookRepository = ebookRepository;
_storageService = storageService;
_scopeFactory = scopeFactory;
}
public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
{
string epubPath;
string? coverUrl;
try
{
// 1. Save Files
epubPath = await _storageService.SaveEbookAsync(request.EpubData, $"{request.Title}.epub");
coverUrl = request.CoverImage != null && request.CoverImage.Length > 0
? await _storageService.SaveCoverAsync(request.CoverImage, $"{request.Title}_cover.jpg")
: null;
}
catch (IOException ex)
{
return Result.Fail(new Error($"Storage I/O failure: {ex.Message}").CausedBy(ex));
}
catch (Exception ex)
{
return Result.Fail(new Error($"Storage failure: {ex.Message}").CausedBy(ex));
}
try
{
// 2. Resolve Author (case-insensitive via repository)
var authorName = string.IsNullOrWhiteSpace(request.AuthorName)
? "Unknown Author"
: request.AuthorName.Trim();
var author = await _ebookRepository.FindAuthorByNameAsync(authorName, cancellationToken);
if (author == null)
{
author = new Author { Name = authorName };
_ebookRepository.AddAuthor(author);
}
// 3. Create Ebook
var ebook = new Ebook
{
Title = request.Title,
Author = author,
FilePath = epubPath,
CoverUrl = coverUrl,
Description = request.Description,
UserId = request.UserId,
TenantId = request.TenantId,
AddedDate = DateTime.UtcNow
};
_ebookRepository.AddEbook(ebook);
await _ebookRepository.SaveChangesAsync(cancellationToken);
// 4. Trigger asynchronous background processing and vector indexing
_ = Task.Run(async () =>
{
try
{
using var scope = _scopeFactory.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId));
}
catch (Exception)
{
// Swallowed to prevent ThreadPool crashes
}
});
return Result.Ok(ebook.Id);
}
catch (Exception ex)
{
return Result.Fail(new Error($"Database error during ingestion: {ex.Message}").CausedBy(ex));
}
}
}
@@ -0,0 +1,9 @@
namespace NexusReader.Application.Commands.Library;
public record IngestEbookRequest(
string Title,
string AuthorName,
string? CoverImageBase64,
string EpubDataBase64,
string? Description = null
);
@@ -0,0 +1,177 @@
using System.Text.RegularExpressions;
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Commands.Library;
public record ProcessEbookCommand(
Guid EbookId,
string UserId,
string TenantId
) : ICommand<bool>;
public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, Result<bool>>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly IKnowledgeService _knowledgeService;
private readonly IEpubExtractor _epubExtractor;
private readonly ISyncBroadcaster _broadcaster;
private readonly ILogger<ProcessEbookCommandHandler> _logger;
public ProcessEbookCommandHandler(
IDbContextFactory<AppDbContext> dbContextFactory,
IKnowledgeService knowledgeService,
IEpubExtractor epubExtractor,
ISyncBroadcaster broadcaster,
ILogger<ProcessEbookCommandHandler> logger)
{
_dbContextFactory = dbContextFactory;
_knowledgeService = knowledgeService;
_epubExtractor = epubExtractor;
_broadcaster = broadcaster;
_logger = logger;
}
public async Task<Result<bool>> Handle(ProcessEbookCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("[ProcessEbook] Starting background processing for Ebook: {EbookId}", request.EbookId);
try
{
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken);
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var ebook = await dbContext.Ebooks.FindAsync(new object[] { request.EbookId }, cancellationToken);
if (ebook == null)
{
_logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId);
return Result.Fail<bool>($"Ebook nie znaleziony w bazie danych: {request.EbookId}");
}
_logger.LogInformation("[ProcessEbook] Extracting chapters text for Ebook: {Title} ({FilePath})", ebook.Title, ebook.FilePath);
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Otwieranie i parsowanie pliku EPUB...", 0.1, cancellationToken);
var extractionResult = await _epubExtractor.ExtractChaptersTextAsync(ebook.FilePath, cancellationToken);
if (extractionResult.IsFailed)
{
var errorMsg = extractionResult.Errors.FirstOrDefault()?.Message ?? "Failed to extract text chapters.";
_logger.LogError("[ProcessEbook] Extraction failed: {Error}", errorMsg);
return Result.Fail<bool>(extractionResult.Errors);
}
var chapters = extractionResult.Value;
if (chapters == null || !chapters.Any())
{
_logger.LogWarning("[ProcessEbook] EPUB has no readable content files: {EbookId}", request.EbookId);
return Result.Fail<bool>("EPUB nie zawiera czytelnych rozdziałów.");
}
int totalChapters = chapters.Count;
_logger.LogInformation("[ProcessEbook] Processing {Count} chapters for Ebook: {Title}", totalChapters, ebook.Title);
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, $"Analizowanie struktury ({totalChapters} rozdziałów)...", 0.15, cancellationToken);
int processedChapters = 0;
for (int i = 0; i < totalChapters; i++)
{
var cleanText = chapters[i];
if (cleanText.Length < 100)
{
_logger.LogInformation("[ProcessEbook] Skipping chapter {Index} (text too short: {Length} chars)", i, cleanText.Length);
processedChapters++;
continue;
}
// Chunk the text to maintain granular Knowledge Units
var chunks = ChunkText(cleanText, 3000);
_logger.LogInformation("[ProcessEbook] Chapter {Index} split into {ChunkCount} chunk(s)", i, chunks.Count);
foreach (var chunk in chunks)
{
try
{
// Invoke GetKnowledgeMapAsync to extract, embed, and upsert knowledge units
var result = await _knowledgeService.GetKnowledgeMapAsync(chunk, request.TenantId, request.EbookId, cancellationToken);
if (result.IsFailed)
{
_logger.LogWarning("[ProcessEbook] Failed to generate knowledge map for a chunk of chapter {Index}: {Error}", i, result.Errors.FirstOrDefault()?.Message);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[ProcessEbook] Exception during AI vectorization of chapter {Index} chunk", i);
}
}
processedChapters++;
double progress = 0.15 + (0.75 * processedChapters / totalChapters);
await _broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
$"Przetwarzanie rozdziału {processedChapters} z {totalChapters} przez AI...",
progress,
cancellationToken);
}
// Mark the ebook as ready
ebook.IsReadyForReading = true;
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title);
await _broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
"Indeksowanie wektorowe e-booka przez Nexus AI zakończone pomyślnie!",
1.0,
cancellationToken);
return Result.Ok(true);
}
catch (Exception ex)
{
_logger.LogError(ex, "[ProcessEbook] Critical error during background EPUB vectorization of ebook {EbookId}", request.EbookId);
await _broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
$"Błąd indeksowania: {ex.Message}",
1.0,
cancellationToken);
return Result.Fail<bool>(new Error("Wystąpił błąd podczas indeksowania e-booka przez AI").CausedBy(ex));
}
}
private static List<string> ChunkText(string text, int maxWords = 3000)
{
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var chunks = new List<string>();
if (words.Length <= maxWords)
{
chunks.Add(text);
return chunks;
}
var currentChunk = new List<string>();
int count = 0;
foreach (var word in words)
{
currentChunk.Add(word);
count++;
if (count >= maxWords)
{
chunks.Add(string.Join(" ", currentChunk));
currentChunk.Clear();
count = 0;
}
}
if (currentChunk.Any())
{
chunks.Add(string.Join(" ", currentChunk));
}
return chunks;
}
}
@@ -0,0 +1,10 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Commands.Quiz;
public record SubmitQuizResultCommand(
string UserId,
string Topic,
int Score,
int TotalQuestions) : ICommand;
@@ -0,0 +1,44 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Commands.Quiz;
public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizResultCommand>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public SubmitQuizResultCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Result> Handle(SubmitQuizResultCommand request, CancellationToken cancellationToken)
{
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
return Result.Fail("User not found.");
}
var quizResult = new QuizResult
{
Id = Guid.NewGuid(),
UserId = request.UserId,
TenantId = string.IsNullOrEmpty(user.TenantId) ? "global" : user.TenantId,
Topic = request.Topic,
Score = request.Score,
TotalQuestions = request.TotalQuestions,
CompletedDate = DateTime.UtcNow
};
context.QuizResults.Add(quizResult);
await context.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
}
@@ -0,0 +1,13 @@
using FluentResults;
using MediatR;
namespace NexusReader.Application.Commands.Sync;
public record UpdateReadingProgressCommand(
string PageId,
string UserId,
Guid EbookId,
double Progress,
string? ChapterTitle,
int ChapterIndex,
string? ExcludedConnectionId = null) : IRequest<Result>;
@@ -0,0 +1,61 @@
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Commands.Sync;
/// <summary>
/// Handles the <see cref="UpdateReadingProgressCommand"/>.
/// Persists the user's reading position and broadcasts the update to other connected devices.
/// </summary>
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly ISyncBroadcaster _broadcaster;
public UpdateReadingProgressCommandHandler(
IDbContextFactory<AppDbContext> dbContextFactory,
ISyncBroadcaster broadcaster)
{
_dbContextFactory = dbContextFactory;
_broadcaster = broadcaster;
}
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);
if (user == null)
{
return Result.Fail("User not found.");
}
var now = DateTime.UtcNow;
user.LastReadPageId = request.PageId;
user.LastReadAt = now;
var ebook = await context.Ebooks.FirstOrDefaultAsync(e => e.Id == request.EbookId, cancellationToken);
if (ebook != null)
{
ebook.Progress = request.Progress;
ebook.LastChapter = request.ChapterTitle;
ebook.LastChapterIndex = request.ChapterIndex;
ebook.LastReadDate = now;
}
await context.SaveChangesAsync(cancellationToken);
// Broadcast to other devices via the abstracted broadcaster
await _broadcaster.BroadcastProgressAsync(
request.UserId,
request.PageId,
now,
request.ExcludedConnectionId,
cancellationToken);
return Result.Ok();
}
}
@@ -0,0 +1,8 @@
namespace NexusReader.Application.Constants;
public static class PlanConstants
{
public const string DefaultPlanName = "Free";
public const int DefaultTokenLimit = 1000;
public const string DefaultActivityLabel = "Brak aktywności";
}
@@ -0,0 +1,10 @@
namespace NexusReader.Application.Constants;
public static class StorageKeys
{
public const string AuthToken = "nexus_auth_token";
public const string RefreshToken = "nexus_refresh_token";
public const string UserEmail = "nexus_user_email";
public const string UserTenant = "nexus_user_tenant";
public const string UserRoles = "nexus_user_roles";
}
@@ -0,0 +1,18 @@
using System.Collections.Generic;
namespace NexusReader.Application.DTOs.AI;
public class GroundedResponseDto
{
public string Answer { get; set; } = string.Empty;
public List<CitationDto> Citations { get; set; } = new();
}
public class CitationDto
{
public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID
public string Snippet { get; set; } = string.Empty; // Verified text snippet from context
public string SourceBook { get; set; } = string.Empty; // Book title or description
public string? Author { get; set; }
public int? PageNumber { get; set; }
}
@@ -13,10 +13,15 @@ public record QuizQuestion(
[property: JsonPropertyName("correct_index")] int CorrectIndex [property: JsonPropertyName("correct_index")] int CorrectIndex
); );
public record KnowledgeUnitDto(string Id, string Type, string Content, Dictionary<string, object>? Metadata = null);
public record KnowledgeLinkDto(string Source, string Target, string Relation);
public record KnowledgePacket public record KnowledgePacket
{ {
[JsonPropertyName("concepts")] public List<KeyConcept> Concepts { get; init; } = new(); [JsonPropertyName("concepts")] public List<KeyConcept> Concepts { get; init; } = new();
[JsonPropertyName("quizzes")] public List<QuizQuestion> Quizzes { get; init; } = new(); [JsonPropertyName("quizzes")] public List<QuizQuestion> Quizzes { get; init; } = new();
[JsonPropertyName("units")] public List<KnowledgeUnitDto> Units { get; init; } = new();
[JsonPropertyName("links")] public List<KnowledgeLinkDto> Links { get; init; } = new();
[JsonPropertyName("graph")] public NexusReader.Application.Queries.Graph.GraphDataDto? Graph { get; init; } [JsonPropertyName("graph")] public NexusReader.Application.Queries.Graph.GraphDataDto? Graph { get; init; }
[JsonPropertyName("summary")] public string? Summary { get; init; } [JsonPropertyName("summary")] public string? Summary { get; init; }
} }
@@ -0,0 +1,8 @@
namespace NexusReader.Application.DTOs.AI;
public class RelevantContext
{
public string Text { get; set; } = string.Empty;
public string SourceId { get; set; } = string.Empty; // ContentHash or EbookTitle
public double Confidence { get; set; }
}
@@ -0,0 +1,11 @@
namespace NexusReader.Application.DTOs.AI;
public class SemanticSearchResultDto
{
public string ContentHash { get; set; } = string.Empty;
public string Snippet { get; set; } = string.Empty;
public string? UnitType { get; set; }
public float RelevanceScore { get; set; }
public string? SourceBookTitle { get; set; }
public Dictionary<string, object>? Metadata { get; set; } // Bonus context
}
@@ -0,0 +1,7 @@
namespace NexusReader.Application.DTOs.User;
public record AuthorDto
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
}
@@ -0,0 +1,10 @@
namespace NexusReader.Application.DTOs.User;
public record SubscriptionPlanDto
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public int AITokenLimit { get; init; }
public bool IsUnlimitedTokens { get; init; }
public decimal MonthlyPrice { get; init; }
}
@@ -0,0 +1,62 @@
using NexusReader.Application.Constants;
namespace NexusReader.Application.DTOs.User;
public record UserProfileDto
{
public string Email { get; init; } = string.Empty;
public string UserId { get; init; } = string.Empty;
public int AITokensUsed { get; init; }
public Guid TenantId { get; init; }
/// <summary>
/// Relational data for the current subscription plan.
/// </summary>
public SubscriptionPlanDto Plan { get; init; } = new();
public int AverageQuizScore { get; init; }
public string? DisplayName { get; init; }
public int BooksReadCount { get; init; }
public int ConceptsMappedCount { get; init; }
public LastReadBookDto? LastReadBook { get; init; }
public IReadOnlyList<QuizResultDto> RecentQuizzes { get; init; } = Array.Empty<QuizResultDto>();
public IReadOnlyList<MappedConceptDto> MappedConcepts { get; init; } = Array.Empty<MappedConceptDto>();
public string[] Roles { get; init; } = Array.Empty<string>();
// Helper properties for UI compatibility
public string CurrentPlan => Plan?.Name ?? PlanConstants.DefaultPlanName;
public int AITokenLimit => Plan?.AITokenLimit ?? PlanConstants.DefaultTokenLimit;
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
}
public record MappedConceptDto
{
public string Id { get; init; } = string.Empty;
public string Type { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public string DisplayLabel => Content.Length > 25 ? Content.Substring(0, 22) + "..." : Content;
}
public record LastReadBookDto
{
public Guid Id { get; init; }
public string Title { get; init; } = string.Empty;
public AuthorDto Author { get; init; } = new();
public string? CoverUrl { get; init; }
public double Progress { get; init; }
public string? LastChapter { get; init; }
public int LastChapterIndex { get; init; }
public string? Description { get; init; }
public bool IsReadyForReading { get; init; }
}
public record QuizResultDto
{
public Guid Id { get; init; }
public string Topic { get; init; } = string.Empty;
public int Score { get; init; }
public int TotalQuestions { get; init; }
public double Percentage { get; init; }
public DateTime CompletedDate { get; init; }
}
@@ -8,12 +8,10 @@ public static class DependencyInjection
public static IServiceCollection AddApplication(this IServiceCollection services) public static IServiceCollection AddApplication(this IServiceCollection services)
{ {
services.AddMapsterConfiguration(); services.AddMapsterConfiguration();
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
services.AddMediatR(config =>
{
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
});
return services; return services;
} }
public static System.Reflection.Assembly Assembly => typeof(DependencyInjection).Assembly;
} }
@@ -1,7 +1,8 @@
using Mapster; using Mapster;
using MapsterMapper; using MapsterMapper;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.Reflection; using NexusReader.Domain.Entities;
using NexusReader.Application.DTOs.User;
namespace NexusReader.Application.Mappings; namespace NexusReader.Application.Mappings;
@@ -11,8 +12,9 @@ public static class MappingConfig
{ {
var config = TypeAdapterConfig.GlobalSettings; var config = TypeAdapterConfig.GlobalSettings;
// Manual registration for AOT (or use Source Generator) config.NewConfig<NexusUser, UserProfileDto>();
// config.NewConfig<Source, Destination>(); config.NewConfig<Ebook, LastReadBookDto>()
.Map(dest => dest.Description, src => src.Description);
services.AddSingleton(config); services.AddSingleton(config);
services.AddScoped<IMapper, ServiceMapper>(); services.AddScoped<IMapper, ServiceMapper>();
@@ -20,3 +22,4 @@ public static class MappingConfig
return services; return services;
} }
} }
@@ -2,15 +2,20 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" /> <ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" />
<ProjectReference Include="..\NexusReader.Data\NexusReader.Data.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentResults" Version="4.0.0" /> <PackageReference Include="FluentResults" />
<PackageReference Include="Mapster" Version="10.0.7" /> <PackageReference Include="Mapster" />
<PackageReference Include="Mapster.DependencyInjection" Version="10.0.7" /> <PackageReference Include="Mapster.DependencyInjection" />
<PackageReference Include="MediatR" Version="12.1.1" /> <PackageReference Include="MediatR" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Authorization" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" />
<PackageReference Include="Pgvector.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.Resilience" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
@@ -2,4 +2,8 @@ using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Graph; namespace NexusReader.Application.Queries.Graph;
public record GetKnowledgeGraphQuery : IQuery<GraphDataDto>; /// <param name="Text">Chapter or page content to extract the graph from.</param>
/// <param name="TenantId">Tenant scope for knowledge extraction and caching.</param>
/// <param name="EbookId">Optional Ebook ID to link the knowledge units to.</param>
public record GetKnowledgeGraphQuery(string Text, string TenantId, Guid? EbookId = null) : IQuery<GraphDataDto>;
@@ -1,15 +1,34 @@
using FluentResults; using FluentResults;
using NexusReader.Application.Abstractions.Messaging; using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Application.Queries.Graph; namespace NexusReader.Application.Queries.Graph;
internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledgeGraphQuery, GraphDataDto> internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledgeGraphQuery, GraphDataDto>
{ {
public Task<Result<GraphDataDto>> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken) private readonly IKnowledgeService _knowledgeService;
{
var nodes = new List<GraphNodeDto>();
var links = new List<GraphLinkDto>();
return Task.FromResult(Result.Ok(new GraphDataDto { Nodes = nodes, Links = links })); public GetKnowledgeGraphQueryHandler(IKnowledgeService knowledgeService)
{
_knowledgeService = knowledgeService;
}
public async Task<Result<GraphDataDto>> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Text))
return Result.Ok(new GraphDataDto());
var result = await _knowledgeService.GetGraphDataAsync(
request.Text,
request.TenantId,
ebookId: request.EbookId,
cancellationToken: cancellationToken);
if (result.IsFailed)
return Result.Fail<GraphDataDto>(result.Errors);
var graph = result.Value.Graph;
return graph is null ? Result.Ok(new GraphDataDto()) : Result.Ok(graph);
} }
} }
@@ -1,9 +1,27 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace NexusReader.Application.Queries.Graph; namespace NexusReader.Application.Queries.Graph;
public record GraphNodeDto(string Id, string Label, string Group); public record GraphNodeDto(
public record GraphLinkDto(string Source, string Target, int Value); [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("label")] string Label,
[property: JsonPropertyName("group")] string Group,
[property: JsonPropertyName("description")] string? Description = null,
[property: JsonPropertyName("type")] string? Type = null,
[property: JsonPropertyName("summary")] string? Summary = null,
[property: JsonPropertyName("key_terms")] List<string>? KeyTerms = null
);
public record GraphLinkDto(
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("target")] string Target,
[property: JsonPropertyName("type")] string RelationType,
[property: JsonPropertyName("value")] int Value = 1
);
public record GraphDataDto public record GraphDataDto
{ {
public List<GraphNodeDto> Nodes { get; init; } = new(); [JsonPropertyName("nodes")] public List<GraphNodeDto> Nodes { get; init; } = new();
public List<GraphLinkDto> Links { get; init; } = new(); [JsonPropertyName("links")] public List<GraphLinkDto> Links { get; init; } = new();
} }
@@ -0,0 +1,37 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using MediatR;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.AI;
namespace NexusReader.Application.Queries.Library;
public record AskLibraryQuestionQuery(string Question, string TenantId, Guid? EbookId = null, int Limit = 5)
: IRequest<Result<GroundedResponseDto>>;
public class AskLibraryQuestionQueryHandler : IRequestHandler<AskLibraryQuestionQuery, Result<GroundedResponseDto>>
{
private readonly IKnowledgeService _knowledgeService;
public AskLibraryQuestionQueryHandler(IKnowledgeService knowledgeService)
{
_knowledgeService = knowledgeService;
}
public async Task<Result<GroundedResponseDto>> Handle(AskLibraryQuestionQuery request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Question))
{
return Result.Fail("Question cannot be empty.");
}
return await _knowledgeService.AskQuestionAsync(
request.Question,
request.TenantId,
request.EbookId,
request.Limit,
cancellationToken);
}
}
@@ -0,0 +1,47 @@
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.DTOs.User;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Queries.Library;
public record GetMyEbooksQuery(string UserId) : IRequest<Result<List<LastReadBookDto>>>;
public class GetMyEbooksQueryHandler : IRequestHandler<GetMyEbooksQuery, Result<List<LastReadBookDto>>>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public GetMyEbooksQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Result<List<LastReadBookDto>>> Handle(GetMyEbooksQuery request, CancellationToken cancellationToken)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var ebooks = await dbContext.Ebooks
.Where(e => e.UserId == request.UserId)
.OrderByDescending(e => e.LastReadDate ?? e.AddedDate)
.Select(e => new LastReadBookDto
{
Id = e.Id,
Title = e.Title,
Author = new AuthorDto
{
Id = e.Author.Id,
Name = e.Author.Name
},
CoverUrl = e.CoverUrl,
Progress = e.Progress,
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
LastChapterIndex = e.LastChapterIndex,
Description = e.Description,
IsReadyForReading = e.IsReadyForReading
})
.ToListAsync(cancellationToken);
return Result.Ok(ebooks);
}
}
@@ -0,0 +1,33 @@
using FluentResults;
using MediatR;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.AI;
namespace NexusReader.Application.Queries.Library;
public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId, int Limit = 5)
: IRequest<Result<List<SemanticSearchResultDto>>>;
public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>>
{
private readonly IKnowledgeService _knowledgeService;
public SearchLibrarySemanticallyQueryHandler(IKnowledgeService knowledgeService)
{
_knowledgeService = knowledgeService;
}
public async Task<Result<List<SemanticSearchResultDto>>> Handle(SearchLibrarySemanticallyQuery request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.QueryText))
{
return Result.Fail("Query text cannot be empty.");
}
return await _knowledgeService.SearchLibrarySemanticallyAsync(
request.QueryText,
request.TenantId,
request.Limit,
cancellationToken);
}
}
@@ -1,5 +0,0 @@
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Quiz;
public record GetQuizQuestionsQuery(string ContextBlockId) : IQuery<QuizDto>;
@@ -1,20 +0,0 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Application.Queries.Quiz;
internal sealed class GetQuizQuestionsQueryHandler : IQueryHandler<GetQuizQuestionsQuery, QuizDto>
{
private readonly IAiGenerateQuizService _aiService;
public GetQuizQuestionsQueryHandler(IAiGenerateQuizService aiService)
{
_aiService = aiService;
}
public async Task<Result<QuizDto>> Handle(GetQuizQuestionsQuery request, CancellationToken cancellationToken)
{
return await _aiService.GenerateQuizAsync(request.ContextBlockId, cancellationToken);
}
}
@@ -2,4 +2,13 @@ using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Reader; namespace NexusReader.Application.Queries.Reader;
public record GetReaderPageQuery(int ChapterIndex = 0) : IQuery<ReaderPageViewModel>; /// <summary>
/// Query to retrieve a specific chapter of a user's ebook.
/// </summary>
/// <param name="EbookId">The ID of the ebook to read.</param>
/// <param name="ChapterIndex">Zero-based chapter index.</param>
/// <param name="UserId">The authenticated user's ID for tenant isolation.</param>
public record GetReaderPageQuery(
Guid EbookId,
int ChapterIndex = 0,
string? UserId = null) : IQuery<ReaderPageViewModel>;
@@ -6,15 +6,15 @@ namespace NexusReader.Application.Queries.Reader;
internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel> internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel>
{ {
private readonly IEpubService _epubService; private readonly IEpubReader _epubReader;
public GetReaderPageQueryHandler(IEpubService epubService) public GetReaderPageQueryHandler(IEpubReader epubReader)
{ {
_epubService = epubService; _epubReader = epubReader;
} }
public async Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken) public Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
{ {
return await _epubService.GetEpubContentAsync(request.ChapterIndex); return _epubReader.GetEpubContentAsync(request.EbookId, request.ChapterIndex, request.UserId, cancellationToken);
} }
} }
@@ -0,0 +1,27 @@
namespace NexusReader.Application.Queries.Reader;
/// <summary>
/// Represents metadata extracted from a local EPUB file.
/// </summary>
public record LocalEpubMetadata
{
/// <summary>
/// The title of the book.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// The author(s) of the book.
/// </summary>
public string Author { get; set; } = string.Empty;
/// <summary>
/// The raw bytes of the cover image, if available.
/// </summary>
public byte[]? CoverImage { get; set; }
/// <summary>
/// The description or summary of the book.
/// </summary>
public string? Description { get; set; }
}
@@ -8,4 +8,4 @@ public abstract record ContentBlock(string Id);
public record TextSegmentBlock(string Id, string Content) : ContentBlock(Id); public record TextSegmentBlock(string Id, string Content) : ContentBlock(Id);
public record AiActionTriggerBlock(string Id, string Dialogue, List<string> ActionOptions) : ContentBlock(Id); public record AiActionTriggerBlock(string Id, string Dialogue, List<string> ActionOptions) : ContentBlock(Id);
public record ReaderPageViewModel(List<ContentBlock> Blocks, int CurrentChapterIndex, int TotalChapters, string ChapterTitle); public record ReaderPageViewModel(List<ContentBlock> Blocks, int CurrentChapterIndex, int TotalChapters, string ChapterTitle, Guid EbookId = default);
@@ -0,0 +1,7 @@
using MediatR;
using FluentResults;
using NexusReader.Application.DTOs.User;
namespace NexusReader.Application.Queries.User;
public record GetUserProfileQuery(string UserId) : IRequest<Result<UserProfileDto>>;
@@ -0,0 +1,92 @@
using MediatR;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.DTOs.User;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Queries.User;
public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, Result<UserProfileDto>>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public GetUserProfileQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var profile = await dbContext.Users
.Where(u => u.Id == request.UserId)
.Select(u => new UserProfileDto
{
Email = u.Email ?? string.Empty,
UserId = u.Id,
AITokensUsed = u.AITokensUsed,
TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty,
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
{
Id = u.SubscriptionPlan.Id,
Name = u.SubscriptionPlan.PlanName,
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
} : new SubscriptionPlanDto(),
AverageQuizScore = u.QuizResults.Any(q => q.TotalQuestions > 0)
? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100)
: 0,
DisplayName = u.DisplayName,
BooksReadCount = u.Ebooks.Count(),
ConceptsMappedCount = dbContext.KnowledgeUnits.Count(k => k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)),
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
{
Id = e.Id,
Title = e.Title,
Author = new AuthorDto
{
Id = e.Author.Id,
Name = e.Author.Name
},
CoverUrl = e.CoverUrl,
Progress = e.Progress,
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
LastChapterIndex = e.LastChapterIndex,
Description = e.Description,
IsReadyForReading = e.IsReadyForReading
}).FirstOrDefault(),
RecentQuizzes = u.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
{
Id = q.Id,
Topic = q.Topic,
Score = q.Score,
TotalQuestions = q.TotalQuestions,
Percentage = q.Percentage,
CompletedDate = q.CompletedDate
}).ToList(),
MappedConcepts = dbContext.KnowledgeUnits
.Where(k => k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId))
.OrderByDescending(k => k.CreatedAt)
.Take(6)
.Select(k => new MappedConceptDto
{
Id = k.Id,
Type = k.Type.ToString(),
Content = k.Content
})
.ToList(),
Roles = dbContext.UserRoles
.Where(ur => ur.UserId == u.Id)
.Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!)
.ToArray()
})
.FirstOrDefaultAsync(cancellationToken);
if (profile == null)
{
return Result.Fail("Profile not found.");
}
return Result.Ok(profile);
}
}
@@ -2,16 +2,19 @@ using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Security.Authorization; namespace NexusReader.Application.Security.Authorization;
public class ProUserHandler : AuthorizationHandler<ProUserRequirement> public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
{ {
private readonly UserManager<NexusUser> _userManager; private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public ProUserHandler(UserManager<NexusUser> userManager) public ProUserHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{ {
_userManager = userManager; _dbContextFactory = dbContextFactory;
} }
protected override async Task HandleRequirementAsync( protected override async Task HandleRequirementAsync(
@@ -24,14 +27,18 @@ public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
return; return;
} }
var user = await _userManager.FindByIdAsync(userId); using var db = _dbContextFactory.CreateDbContext();
var user = await db.Users
.Include(u => u.SubscriptionPlan)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) if (user == null)
{ {
return; return;
} }
// Rule 1: Explicit Pro plan // Rule 1: Unlimited access
if (user.CurrentPlan == "Pro") if (user.SubscriptionPlan?.IsUnlimitedTokens == true)
{ {
context.Succeed(requirement); context.Succeed(requirement);
return; return;
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence; using NexusReader.Data.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace NexusReader.Infrastructure.Migrations namespace NexusReader.Data.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260428184727_InitialPostgres")] [Migration("20260428184727_InitialPostgres")]
@@ -4,7 +4,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace NexusReader.Infrastructure.Migrations namespace NexusReader.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class InitialPostgres : Migration public partial class InitialPostgres : Migration
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence; using NexusReader.Data.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace NexusReader.Infrastructure.Migrations namespace NexusReader.Data.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260428185239_IncreaseHashLength")] [Migration("20260428185239_IncreaseHashLength")]
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace NexusReader.Infrastructure.Migrations namespace NexusReader.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class IncreaseHashLength : Migration public partial class IncreaseHashLength : Migration
@@ -4,12 +4,12 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence; using NexusReader.Data.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace NexusReader.Infrastructure.Migrations namespace NexusReader.Data.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260429080302_AddQuizResults")] [Migration("20260429080302_AddQuizResults")]
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace NexusReader.Infrastructure.Migrations namespace NexusReader.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddQuizResults : Migration public partial class AddQuizResults : Migration
@@ -2,24 +2,29 @@
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Infrastructure.Persistence; using NexusReader.Data.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
#nullable disable #nullable disable
namespace NexusReader.Infrastructure.Migrations namespace NexusReader.Data.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot [Migration("20260503175906_FinalNormalizedSubscriptionArchitecture")]
partial class FinalNormalizedSubscriptionArchitecture
{ {
protected override void BuildModel(ModelBuilder modelBuilder) /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
@@ -161,7 +166,7 @@ namespace NexusReader.Infrastructure.Migrations
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<DateTime>("AddedDate") b.Property<DateTime>("AddedDate")
.HasColumnType("timestamp without time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("Author") b.Property<string>("Author")
.IsRequired() .IsRequired()
@@ -176,7 +181,12 @@ namespace NexusReader.Infrastructure.Migrations
.HasColumnType("text"); .HasColumnType("text");
b.Property<DateTime?>("LastReadDate") b.Property<DateTime?>("LastReadDate")
.HasColumnType("timestamp without time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
@@ -189,11 +199,91 @@ namespace NexusReader.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("Ebooks"); 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 => modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -212,9 +302,9 @@ namespace NexusReader.Infrastructure.Migrations
.IsConcurrencyToken() .IsConcurrencyToken()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("CurrentPlan") b.Property<string>("DisplayName")
.IsRequired() .HasMaxLength(100)
.HasColumnType("text"); .HasColumnType("character varying(100)");
b.Property<string>("Email") b.Property<string>("Email")
.HasMaxLength(256) .HasMaxLength(256)
@@ -223,6 +313,16 @@ namespace NexusReader.Infrastructure.Migrations
b.Property<bool>("EmailConfirmed") b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean"); .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") b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -249,8 +349,15 @@ namespace NexusReader.Infrastructure.Migrations
b.Property<string>("SecurityStamp") b.Property<string>("SecurityStamp")
.HasColumnType("text"); .HasColumnType("text");
b.Property<Guid>("TenantId") b.Property<int>("SubscriptionPlanId")
.HasColumnType("uuid"); .ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<bool>("TwoFactorEnabled") b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -268,6 +375,10 @@ namespace NexusReader.Infrastructure.Migrations
.IsUnique() .IsUnique()
.HasDatabaseName("UserNameIndex"); .HasDatabaseName("UserNameIndex");
b.HasIndex("SubscriptionPlanId");
b.HasIndex("TenantId");
b.ToTable("AspNetUsers", (string)null); b.ToTable("AspNetUsers", (string)null);
}); });
@@ -278,11 +389,16 @@ namespace NexusReader.Infrastructure.Migrations
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<DateTime>("CompletedDate") b.Property<DateTime>("CompletedDate")
.HasColumnType("timestamp without time zone"); .HasColumnType("timestamp with time zone");
b.Property<int>("Score") b.Property<int>("Score")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Topic") b.Property<string>("Topic")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -296,6 +412,8 @@ namespace NexusReader.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("QuizResults"); b.ToTable("QuizResults");
@@ -308,7 +426,7 @@ namespace NexusReader.Infrastructure.Migrations
.HasColumnType("character varying(128)"); .HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("JsonData") b.Property<string>("JsonData")
.IsRequired() .IsRequired()
@@ -319,19 +437,99 @@ namespace NexusReader.Infrastructure.Migrations
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
b.Property<string>("OriginalText")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PromptVersion") b.Property<string>("PromptVersion")
.IsRequired() .IsRequired()
.HasMaxLength(10) .HasMaxLength(10)
.HasColumnType("character varying(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.HasKey("ContentHash");
b.HasIndex("ContentHash") b.HasIndex("ContentHash")
.IsUnique(); .IsUnique();
b.HasIndex("TenantId");
b.ToTable("SemanticKnowledgeCache"); b.ToTable("SemanticKnowledgeCache");
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("PlanName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("PlanName")
.IsUnique();
b.ToTable("SubscriptionPlans");
b.HasData(
new
{
Id = 1,
AITokenLimit = 1000,
MonthlyPrice = 0m,
PlanName = "Free",
StripeProductId = ""
},
new
{
Id = 2,
AITokenLimit = 10000,
MonthlyPrice = 9.99m,
PlanName = "Basic",
StripeProductId = "prod_basic_placeholder"
},
new
{
Id = 3,
AITokenLimit = 50000,
MonthlyPrice = 19.99m,
PlanName = "Pro",
StripeProductId = "prod_pro_placeholder"
},
new
{
Id = 4,
AITokenLimit = 500000,
MonthlyPrice = 99.99m,
PlanName = "Enterprise",
StripeProductId = "prod_enterprise_placeholder"
});
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{ {
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -394,6 +592,36 @@ namespace NexusReader.Infrastructure.Migrations
b.Navigation("User"); 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 => modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{ {
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User") b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
@@ -405,6 +633,13 @@ namespace NexusReader.Infrastructure.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Navigation("IncomingLinks");
b.Navigation("OutgoingLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{ {
b.Navigation("Ebooks"); b.Navigation("Ebooks");
@@ -0,0 +1,399 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace NexusReader.Data.Migrations
{
/// <inheritdoc />
public partial class FinalNormalizedSubscriptionArchitecture : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CurrentPlan",
table: "AspNetUsers");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "SemanticKnowledgeCache",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
migrationBuilder.AddColumn<string>(
name: "OriginalText",
table: "SemanticKnowledgeCache",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TenantId",
table: "SemanticKnowledgeCache",
type: "character varying(128)",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<Vector>(
name: "Vector",
table: "SemanticKnowledgeCache",
type: "vector(1536)",
nullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "CompletedDate",
table: "QuizResults",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
migrationBuilder.AddColumn<string>(
name: "TenantId",
table: "QuizResults",
type: "character varying(128)",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.AlterColumn<DateTime>(
name: "LastReadDate",
table: "Ebooks",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "AddedDate",
table: "Ebooks",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
migrationBuilder.AddColumn<string>(
name: "TenantId",
table: "Ebooks",
type: "character varying(128)",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.AlterColumn<string>(
name: "TenantId",
table: "AspNetUsers",
type: "character varying(128)",
maxLength: 128,
nullable: false,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<string>(
name: "DisplayName",
table: "AspNetUsers",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastAiActionDate",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastReadAt",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastReadPageId",
table: "AspNetUsers",
type: "character varying(255)",
maxLength: 255,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "SubscriptionPlanId",
table: "AspNetUsers",
type: "integer",
nullable: false,
defaultValue: 1);
migrationBuilder.CreateTable(
name: "KnowledgeUnits",
columns: table => new
{
Id = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
SourceId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Version = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
Content = table.Column<string>(type: "text", nullable: false),
MetadataJson = table.Column<string>(type: "text", nullable: true),
TenantId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Vector = table.Column<Vector>(type: "vector(768)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KnowledgeUnits", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SubscriptionPlans",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PlanName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
AITokenLimit = table.Column<int>(type: "integer", nullable: false),
MonthlyPrice = table.Column<decimal>(type: "numeric", nullable: false),
StripeProductId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SubscriptionPlans", x => x.Id);
});
migrationBuilder.CreateTable(
name: "KnowledgeUnitLinks",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SourceUnitId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
TargetUnitId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
RelationType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KnowledgeUnitLinks", x => x.Id);
table.ForeignKey(
name: "FK_KnowledgeUnitLinks_KnowledgeUnits_SourceUnitId",
column: x => x.SourceUnitId,
principalTable: "KnowledgeUnits",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_KnowledgeUnitLinks_KnowledgeUnits_TargetUnitId",
column: x => x.TargetUnitId,
principalTable: "KnowledgeUnits",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "SubscriptionPlans",
columns: new[] { "Id", "AITokenLimit", "MonthlyPrice", "PlanName", "StripeProductId" },
values: new object[,]
{
{ 1, 1000, 0m, "Free", "" },
{ 2, 10000, 9.99m, "Basic", "prod_basic_placeholder" },
{ 3, 50000, 19.99m, "Pro", "prod_pro_placeholder" },
{ 4, 500000, 99.99m, "Enterprise", "prod_enterprise_placeholder" }
});
migrationBuilder.CreateIndex(
name: "IX_SemanticKnowledgeCache_TenantId",
table: "SemanticKnowledgeCache",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_QuizResults_TenantId",
table: "QuizResults",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_Ebooks_TenantId",
table: "Ebooks",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUsers_SubscriptionPlanId",
table: "AspNetUsers",
column: "SubscriptionPlanId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUsers_TenantId",
table: "AspNetUsers",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_KnowledgeUnitLinks_SourceUnitId",
table: "KnowledgeUnitLinks",
column: "SourceUnitId");
migrationBuilder.CreateIndex(
name: "IX_KnowledgeUnitLinks_TargetUnitId",
table: "KnowledgeUnitLinks",
column: "TargetUnitId");
migrationBuilder.CreateIndex(
name: "IX_KnowledgeUnits_SourceId",
table: "KnowledgeUnits",
column: "SourceId");
migrationBuilder.CreateIndex(
name: "IX_KnowledgeUnits_TenantId",
table: "KnowledgeUnits",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_SubscriptionPlans_PlanName",
table: "SubscriptionPlans",
column: "PlanName",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_AspNetUsers_SubscriptionPlans_SubscriptionPlanId",
table: "AspNetUsers",
column: "SubscriptionPlanId",
principalTable: "SubscriptionPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AspNetUsers_SubscriptionPlans_SubscriptionPlanId",
table: "AspNetUsers");
migrationBuilder.DropTable(
name: "KnowledgeUnitLinks");
migrationBuilder.DropTable(
name: "SubscriptionPlans");
migrationBuilder.DropTable(
name: "KnowledgeUnits");
migrationBuilder.DropIndex(
name: "IX_SemanticKnowledgeCache_TenantId",
table: "SemanticKnowledgeCache");
migrationBuilder.DropIndex(
name: "IX_QuizResults_TenantId",
table: "QuizResults");
migrationBuilder.DropIndex(
name: "IX_Ebooks_TenantId",
table: "Ebooks");
migrationBuilder.DropIndex(
name: "IX_AspNetUsers_SubscriptionPlanId",
table: "AspNetUsers");
migrationBuilder.DropIndex(
name: "IX_AspNetUsers_TenantId",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "OriginalText",
table: "SemanticKnowledgeCache");
migrationBuilder.DropColumn(
name: "TenantId",
table: "SemanticKnowledgeCache");
migrationBuilder.DropColumn(
name: "Vector",
table: "SemanticKnowledgeCache");
migrationBuilder.DropColumn(
name: "TenantId",
table: "QuizResults");
migrationBuilder.DropColumn(
name: "TenantId",
table: "Ebooks");
migrationBuilder.DropColumn(
name: "DisplayName",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "LastAiActionDate",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "LastReadAt",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "LastReadPageId",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "SubscriptionPlanId",
table: "AspNetUsers");
migrationBuilder.AlterDatabase()
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "SemanticKnowledgeCache",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AlterColumn<DateTime>(
name: "CompletedDate",
table: "QuizResults",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AlterColumn<DateTime>(
name: "LastReadDate",
table: "Ebooks",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "AddedDate",
table: "Ebooks",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AlterColumn<Guid>(
name: "TenantId",
table: "AspNetUsers",
type: "uuid",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(128)",
oldMaxLength: 128);
migrationBuilder.AddColumn<string>(
name: "CurrentPlan",
table: "AspNetUsers",
type: "text",
nullable: false,
defaultValue: "");
}
}
}
@@ -0,0 +1,659 @@
// <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
}
}
}
@@ -0,0 +1,71 @@
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);
}
}
}
@@ -0,0 +1,690 @@
// <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("20260510151022_NormalizeAuthor")]
partial class NormalizeAuthor
{
/// <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.Author", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Authors");
});
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<int>("AuthorId")
.HasColumnType("integer");
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("AuthorId");
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.Author", "Author")
.WithMany("Ebooks")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("Ebooks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
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.Author", b =>
{
b.Navigation("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Navigation("IncomingLinks");
b.Navigation("OutgoingLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Navigation("Ebooks");
b.Navigation("QuizResults");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NexusReader.Data.Migrations
{
/// <inheritdoc />
public partial class NormalizeAuthor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Author",
table: "Ebooks");
migrationBuilder.AddColumn<int>(
name: "AuthorId",
table: "Ebooks",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "Authors",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Authors", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Ebooks_AuthorId",
table: "Ebooks",
column: "AuthorId");
migrationBuilder.AddForeignKey(
name: "FK_Ebooks_Authors_AuthorId",
table: "Ebooks",
column: "AuthorId",
principalTable: "Authors",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Ebooks_Authors_AuthorId",
table: "Ebooks");
migrationBuilder.DropTable(
name: "Authors");
migrationBuilder.DropIndex(
name: "IX_Ebooks_AuthorId",
table: "Ebooks");
migrationBuilder.DropColumn(
name: "AuthorId",
table: "Ebooks");
migrationBuilder.AddColumn<string>(
name: "Author",
table: "Ebooks",
type: "character varying(255)",
maxLength: 255,
nullable: false,
defaultValue: "");
}
}
}
@@ -0,0 +1,697 @@
// <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("20260510161155_AddEbookProgressAndChapter")]
partial class AddEbookProgressAndChapter
{
/// <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.Author", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Authors");
});
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<int>("AuthorId")
.HasColumnType("integer");
b.Property<string>("CoverUrl")
.HasColumnType("text");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<string>("LastChapter")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("timestamp with time zone");
b.Property<double>("Progress")
.HasColumnType("double precision");
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("AuthorId");
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.Author", "Author")
.WithMany("Ebooks")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("Ebooks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
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.Author", b =>
{
b.Navigation("Ebooks");
});
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
}
}
}

Some files were not shown because too many files have changed in this diff Show More