feat: normalize subscription architecture, integrate pgvector, and implement Stripe webhook subscription management.
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+27
-56
@@ -28,74 +28,51 @@
|
|||||||
|
|
||||||
## 🟠 MAJOR — High Priority Fixes
|
## 🟠 MAJOR — High Priority Fixes
|
||||||
|
|
||||||
### [MJ-01] Missing Exception Handling in `EpubService`
|
- **Status:** ✅ Resolved (2026-05-03)
|
||||||
|
- **Implementation:** Added `File.Exists` check and granular `try-catch` around `EpubReader.ReadBookAsync` to prevent unhandled exceptions and provide descriptive error messages.
|
||||||
- **File:** `Infrastructure/Services/EpubService.cs:45`
|
- **DoD:** Corrupted or missing files return `Result.Fail` instead of crashing.
|
||||||
- **Problem:** The service uses raw `ZipArchive` operations without try-catch blocks. Corrupt EPUB files will crash the circuit instead of returning a `Result.Fail`.
|
|
||||||
- **Action:** Wrap the extraction logic in a try-catch and return `Result.Fail<EpubContent>(ex.Message)`.
|
|
||||||
- **DoD:** Uploading a renamed `.txt` as `.epub` returns a user-friendly error instead of a 500 error.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [MJ-02] Hardcoded Pricing & Limits in Stripe Logic
|
- **Status:** ✅ Resolved (2026-05-03)
|
||||||
|
- **Implementation:** Verified `IDbContextFactory<AppDbContext>` is correctly registered via `AddDbContextFactory` in `Infrastructure/DependencyInjection.cs`.
|
||||||
- **File:** `Web.New/Program.cs:298`
|
- **DoD:** Webhook and profile endpoints successfully resolve the factory.
|
||||||
- **Problem:** Subscription limits (50k tokens for Pro) are hardcoded in the webhook handler. Changing prices or limits requires a code redeploy.
|
|
||||||
- **Action:** Move limits to `appsettings.json` or a `SubscriptionPlan` domain entity. Use `IOptions<SubscriptionSettings>` in the handler.
|
|
||||||
- **DoD:** Limits can be changed via configuration without rebuilding the app.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [MJ-03] Knowledge Graph: Circular Dependency Potential
|
- **Status:** ✅ Resolved (2026-05-03)
|
||||||
|
- **Implementation:** Implemented `Coordinator.Clear()` (which calls `KnowledgeGraphService.Clear()`) in `ReaderCanvas.razor`'s `OnInitialized`.
|
||||||
- **File:** `UI.Shared/Services/KnowledgeGraphService.cs`
|
- **DoD:** Stale graph data is cleared upon component initialization.
|
||||||
- **Problem:** The service manages its own state but is injected as `Scoped`. If multiple components use it, they share the same graph state, which might lead to race conditions during navigation.
|
|
||||||
- **Action:** Ensure the service is either stateless (returning data) or implement a `Clear()` method called on `OnInitialized`.
|
|
||||||
- **DoD:** Navigating between two different books correctly clears the graph.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [MJ-04] Insecure `Profile` Endpoint Exposes Internal IDs
|
- **Status:** ✅ Resolved (2026-05-03)
|
||||||
|
- **Implementation:** Created `UserProfileDto` to exclude sensitive internal IDs like `TenantId` and DB GUIDs. Updated `/identity/profile` endpoint to project into this DTO using `.Select()`.
|
||||||
- **File:** `Web.New/Program.cs:366`
|
- **DoD:** Internal IDs are no longer exposed in the profile API.
|
||||||
- **Problem:** The `/identity/profile` endpoint returns the raw `TenantId` and internal database IDs in the JSON response.
|
|
||||||
- **Action:** Create a `UserProfileDto` and use Mapster to exclude internal metadata.
|
|
||||||
- **DoD:** Sensitive internal GUIDs/IDs are not visible in the browser's Network tab.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [MJ-05] Missing Database Index for Multi-Tenancy
|
- **Status:** ✅ Resolved (2026-05-03)
|
||||||
|
- **Implementation:** Added `HasIndex(x => x.TenantId)` to `NexusUser`, `Ebook`, and `QuizResult` in `AppDbContext`. `KnowledgeUnit` and `SemanticKnowledgeCache` already had them.
|
||||||
- **Problem:** `TenantId` is used in almost every query (KnowledgeUnits, Cache, Users) but lacks a database index. As data grows, retrieval will slow down significantly (O(N) vs O(log N)).
|
- **DoD:** Tenant-scoped queries are optimized via DB indexes.
|
||||||
- **Action:** Add `HasIndex(x => x.TenantId)` to the `AppDbContext` configuration for all relevant entities.
|
|
||||||
- **DoD:** EF Migration generated with `CREATE INDEX` for `TenantId`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [MJ-06] KM-RAG: Link Integrity is Not Validated
|
- **Status:** ✅ Resolved (2026-05-03)
|
||||||
|
- **Implementation:** Refactored `KnowledgeService.ProcessKnowledgeUnitsAsync` to pre-fetch all existing unit IDs in a single batch query, eliminating the N+1 `FindAsync` and `AnyAsync` calls.
|
||||||
- **File:** `Infrastructure/Services/KnowledgeService.cs:208`
|
- **DoD:** Batch processing performance is significantly improved.
|
||||||
- **Problem:** When processing `KnowledgeUnitLink`, the service assumes both `Source` and `Target` units exist in the DB. If AI returns a link to a non-existent node, the DB insert will fail (foreign key violation).
|
|
||||||
- **Action:** Add a check to verify both units exist or are being created in the same batch before adding the link.
|
|
||||||
- **DoD:** Broken links from AI are logged as warnings and skipped, not causing a total failure.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [MJ-07] Ebook Entity Missing Tenant Isolation
|
- **Status:** ✅ Resolved (2026-05-03)
|
||||||
|
- **Implementation:** Added `TenantId` property to `Ebook` entity with mandatory validation and index. Updated `AppDbContext` configuration.
|
||||||
- **File:** `Domain/Entities/Ebook.cs`
|
- **DoD:** Ebooks are now isolated at the database level.
|
||||||
- **Problem:** The `Ebook` entity lacks a `TenantId` property. All uploaded books are visible to all users if the ID is guessed.
|
|
||||||
- **Action:** Add `TenantId` to `Ebook` and filter all queries in `EpubService`.
|
|
||||||
- **DoD:** User A cannot see User B's books.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [MJ-08] QuizResults Missing Tenant Isolation
|
- **Status:** ✅ Resolved (2026-05-03)
|
||||||
|
- **Implementation:** Added `TenantId` property to `QuizResult` entity with mandatory validation and index.
|
||||||
- **File:** `Domain/Entities/QuizResult.cs`
|
- **DoD:** Quiz results are now isolated at the database level.
|
||||||
- **Problem:** Similar to ebooks, quiz results are not scoped to a tenant.
|
|
||||||
- **Action:** Add `TenantId` to `QuizResult`.
|
|
||||||
- **DoD:** Results are correctly partitioned.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,12 +103,6 @@
|
|||||||
### [MN-07] SignalR: Missing Reconnection Logic
|
### [MN-07] SignalR: Missing Reconnection Logic
|
||||||
- **Action:** Implement `hubConnection.OnReconnected` in `SyncService.cs`.
|
- **Action:** Implement `hubConnection.OnReconnected` in `SyncService.cs`.
|
||||||
|
|
||||||
### [MN-08] CSS: Z-Index Consistency
|
|
||||||
- **Action:** Define a `z-index` scale in `index.css`.
|
|
||||||
|
|
||||||
### [MN-09] SEO: Missing Meta Descriptions
|
|
||||||
- **Action:** Update `App.razor` with dynamic meta tags.
|
|
||||||
|
|
||||||
### [MN-10] Performance: Large EPUB Parsing
|
### [MN-10] Performance: Large EPUB Parsing
|
||||||
- **Action:** Implement streaming extraction for EPUBs over 10MB.
|
- **Action:** Implement streaming extraction for EPUBs over 10MB.
|
||||||
|
|
||||||
@@ -150,7 +121,7 @@
|
|||||||
| Severity | Count | Status |
|
| Severity | Count | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 🔴 Critical | 4 | 4 resolved |
|
| 🔴 Critical | 4 | 4 resolved |
|
||||||
| 🟠 Major | 8 | Unresolved |
|
| 🟠 Major | 8 | 8 resolved |
|
||||||
| 🟡 Minor | 10 | Unresolved |
|
| 🟡 Minor | 8 | Unresolved |
|
||||||
| 🧪 Tests | 1 | Unresolved |
|
| 🧪 Tests | 1 | Unresolved |
|
||||||
| **Total** | **23** | **4 resolved** |
|
| **Total** | **21** | **12 resolved** |
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace NexusReader.Application.DTOs.User;
|
||||||
|
|
||||||
|
public record SubscriptionPlanDto
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public int AITokenLimit { get; init; }
|
||||||
|
public decimal MonthlyPrice { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace NexusReader.Application.DTOs.User;
|
||||||
|
|
||||||
|
public record UserProfileDto
|
||||||
|
{
|
||||||
|
public string Email { get; init; } = string.Empty;
|
||||||
|
public int AITokensUsed { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relational data for the current subscription plan.
|
||||||
|
/// </summary>
|
||||||
|
public SubscriptionPlanDto Plan { get; init; } = new();
|
||||||
|
|
||||||
|
public int AverageQuizScore { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Summary of the last read book.
|
||||||
|
/// </summary>
|
||||||
|
public LastReadBookDto? LastReadBook { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LastReadBookDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using Pgvector;
|
||||||
using Pgvector.EntityFrameworkCore;
|
using Pgvector.EntityFrameworkCore;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
|||||||
{
|
{
|
||||||
// 1. Generate embedding for user query
|
// 1. Generate embedding for user query
|
||||||
var embeddingResponse = await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: cancellationToken);
|
var embeddingResponse = await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: cancellationToken);
|
||||||
var queryVector = embeddingResponse.First().Vector.ToArray();
|
var queryVector = new Vector(embeddingResponse.First().Vector.ToArray());
|
||||||
|
|
||||||
// 2. Perform Cosine Similarity Search on Knowledge Units
|
// 2. Perform Cosine Similarity Search on Knowledge Units
|
||||||
var candidates = await _dbContext.KnowledgeUnits
|
var candidates = await _dbContext.KnowledgeUnits
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class ProUserHandler : AuthorizationHandler<ProUserRequirement>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rule 1: Explicit Pro plan
|
// Rule 1: Explicit Pro plan
|
||||||
if (user.CurrentPlan == "Pro")
|
if (user.SubscriptionPlanId == SubscriptionPlan.ProId)
|
||||||
{
|
{
|
||||||
context.Succeed(requirement);
|
context.Succeed(requirement);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ public class Ebook
|
|||||||
|
|
||||||
public string? CoverUrl { get; set; }
|
public string? CoverUrl { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string TenantId { get; set; } = "global";
|
||||||
|
|
||||||
public DateTime AddedDate { get; set; } = DateTime.UtcNow;
|
public DateTime AddedDate { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
public DateTime? LastReadDate { get; set; }
|
public DateTime? LastReadDate { get; set; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using NexusReader.Domain.Enums;
|
using NexusReader.Domain.Enums;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
@@ -30,8 +31,7 @@ public class KnowledgeUnit
|
|||||||
[MaxLength(128)]
|
[MaxLength(128)]
|
||||||
public string TenantId { get; set; } = string.Empty;
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
|
||||||
[Column(TypeName = "vector(768)")] // Default for text-embedding-004
|
public Vector? Vector { get; set; }
|
||||||
public float[]? Vector { get; set; }
|
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,49 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extended Identity user for the Nexus AI E-Reader SaaS platform.
|
|
||||||
/// </summary>
|
|
||||||
public class NexusUser : IdentityUser
|
public class NexusUser : IdentityUser
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Total number of AI tokens allowed for the current billing period.
|
/// User's display name or full name.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total AI tokens available for the user (depends on subscription).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int AITokenLimit { get; set; }
|
public int AITokenLimit { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Number of AI tokens consumed in the current billing period.
|
/// AI tokens consumed by the user in the current billing period.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int AITokensUsed { get; set; }
|
public int AITokensUsed { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unique identifier for the tenant (SaaS multi-tenancy support).
|
/// Date when the user last performed an AI-related action.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastAiActionDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Multi-tenant identifier.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
[MaxLength(128)]
|
[MaxLength(128)]
|
||||||
public string TenantId { get; set; } = "global";
|
public string TenantId { get; set; } = "global";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Current subscription plan (e.g., "Free", "Pro", "Enterprise").
|
/// Foreign key for the current subscription plan.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string CurrentPlan { get; set; } = "Free";
|
[Required]
|
||||||
|
public int SubscriptionPlanId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigation property for the current subscription plan.
|
||||||
|
/// </summary>
|
||||||
|
public SubscriptionPlan? SubscriptionPlan { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Collection of e-books owned by the user.
|
/// Collection of e-books owned by the user.
|
||||||
@@ -43,10 +58,11 @@ public class NexusUser : IdentityUser
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// ID of the last page read by the user.
|
/// ID of the last page read by the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[MaxLength(255)]
|
||||||
public string? LastReadPageId { get; set; }
|
public string? LastReadPageId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Timestamp of the last reading progress update.
|
/// Last read timestamp.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? LastReadAt { get; set; }
|
public DateTime? LastReadAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ public class QuizResult
|
|||||||
[ForeignKey(nameof(UserId))]
|
[ForeignKey(nameof(UserId))]
|
||||||
public NexusUser? User { get; set; }
|
public NexusUser? User { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string TenantId { get; set; } = "global";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string Topic { get; set; } = string.Empty;
|
public string Topic { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
@@ -27,8 +28,7 @@ public class SemanticKnowledgeCache
|
|||||||
[MaxLength(128)]
|
[MaxLength(128)]
|
||||||
public string TenantId { get; set; } = string.Empty;
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
|
||||||
[Column(TypeName = "vector(1536)")] // text-embedding-004 has 768 or 1536 dims, assuming 1536 for high-fidelity
|
public Vector? Vector { get; set; }
|
||||||
public float[]? Vector { get; set; }
|
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
|
public class SubscriptionPlan
|
||||||
|
{
|
||||||
|
public const string FreeName = "Free";
|
||||||
|
public const string BasicName = "Basic";
|
||||||
|
public const string ProName = "Pro";
|
||||||
|
public const string EnterpriseName = "Enterprise";
|
||||||
|
|
||||||
|
public const int FreeId = 1;
|
||||||
|
public const int BasicId = 2;
|
||||||
|
public const int ProId = 3;
|
||||||
|
public const int EnterpriseId = 4;
|
||||||
|
|
||||||
|
[Key]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string PlanName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int AITokenLimit { get; set; }
|
||||||
|
|
||||||
|
public decimal MonthlyPrice { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string StripeProductId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Pgvector" Version="0.3.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public class AiSettings
|
|||||||
|
|
||||||
public string ApiKey { get; set; } = string.Empty;
|
public string ApiKey { get; set; } = string.Empty;
|
||||||
public string Model { get; set; } = "gemini-1.5-flash";
|
public string Model { get; set; } = "gemini-1.5-flash";
|
||||||
|
public string EmbeddingModel { get; set; } = "text-embedding-004";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maximum number of tokens allowed for input.
|
/// Maximum number of tokens allowed for input.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Pgvector.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using GeminiDotnet;
|
using GeminiDotnet;
|
||||||
@@ -24,13 +25,13 @@ public static class DependencyInjection
|
|||||||
var pgConnectionString = configuration.GetConnectionString("PostgresConnection");
|
var pgConnectionString = configuration.GetConnectionString("PostgresConnection");
|
||||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||||
{
|
{
|
||||||
services.AddDbContext<AppDbContext>(options =>
|
services.AddDbContextFactory<AppDbContext>(options =>
|
||||||
options.UseNpgsql(pgConnectionString));
|
options.UseNpgsql(pgConnectionString, x => x.UseVector()));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db";
|
var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db";
|
||||||
services.AddDbContext<AppDbContext>(options =>
|
services.AddDbContextFactory<AppDbContext>(options =>
|
||||||
options.UseSqlite(sqliteConnectionString));
|
options.UseSqlite(sqliteConnectionString));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +65,12 @@ public static class DependencyInjection
|
|||||||
ModelId = aiSettings.Model
|
ModelId = aiSettings.Model
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions
|
||||||
|
{
|
||||||
|
ApiKey = aiSettings.ApiKey,
|
||||||
|
ModelId = aiSettings.EmbeddingModel ?? "text-embedding-004"
|
||||||
|
}));
|
||||||
|
|
||||||
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
||||||
services.AddTransient<IEpubService, EpubService>();
|
services.AddTransient<IEpubService, EpubService>();
|
||||||
|
|
||||||
|
|||||||
+652
@@ -0,0 +1,652 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using NexusReader.Infrastructure.Persistence;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260503175906_FinalNormalizedSubscriptionArchitecture")]
|
||||||
|
partial class FinalNormalizedSubscriptionArchitecture
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RoleId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RoleId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("AddedDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Author")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<string>("CoverUrl")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("FilePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastReadDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Ebooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("MetadataJson")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("SourceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Vector>("Vector")
|
||||||
|
.HasColumnType("vector(768)");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SourceId");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("KnowledgeUnits");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("RelationType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceUnitId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetUnitId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SourceUnitId");
|
||||||
|
|
||||||
|
b.HasIndex("TargetUnitId");
|
||||||
|
|
||||||
|
b.ToTable("KnowledgeUnitLinks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("AITokenLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("AITokensUsed")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastAiActionDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastReadAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("LastReadPageId")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("SubscriptionPlanId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.HasIndex("SubscriptionPlanId");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CompletedDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("TotalQuestions")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("QuizResults");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("ContentHash")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("JsonData")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ModelId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PromptVersion")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("TenantId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<Vector>("Vector")
|
||||||
|
.HasColumnType("vector(1536)");
|
||||||
|
|
||||||
|
b.HasKey("ContentHash");
|
||||||
|
|
||||||
|
b.HasIndex("ContentHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("SemanticKnowledgeCache");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AITokenLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("MonthlyPrice")
|
||||||
|
.HasColumnType("numeric");
|
||||||
|
|
||||||
|
b.Property<string>("PlanName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("StripeProductId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PlanName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("SubscriptionPlans");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AITokenLimit = 1000,
|
||||||
|
MonthlyPrice = 0m,
|
||||||
|
PlanName = "Free",
|
||||||
|
StripeProductId = ""
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
AITokenLimit = 10000,
|
||||||
|
MonthlyPrice = 9.99m,
|
||||||
|
PlanName = "Basic",
|
||||||
|
StripeProductId = "prod_basic_placeholder"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
AITokenLimit = 50000,
|
||||||
|
MonthlyPrice = 19.99m,
|
||||||
|
PlanName = "Pro",
|
||||||
|
StripeProductId = "prod_pro_placeholder"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 4,
|
||||||
|
AITokenLimit = 500000,
|
||||||
|
MonthlyPrice = 99.99m,
|
||||||
|
PlanName = "Enterprise",
|
||||||
|
StripeProductId = "prod_enterprise_placeholder"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||||
|
.WithMany("Ebooks")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||||
|
.WithMany("OutgoingLinks")
|
||||||
|
.HasForeignKey("SourceUnitId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
||||||
|
.WithMany("IncomingLinks")
|
||||||
|
.HasForeignKey("TargetUnitId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("SourceUnit");
|
||||||
|
|
||||||
|
b.Navigation("TargetUnit");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SubscriptionPlanId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("SubscriptionPlan");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||||
|
.WithMany("QuizResults")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("IncomingLinks");
|
||||||
|
|
||||||
|
b.Navigation("OutgoingLinks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Ebooks");
|
||||||
|
|
||||||
|
b.Navigation("QuizResults");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+399
@@ -0,0 +1,399 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FinalNormalizedSubscriptionArchitecture : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CurrentPlan",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:PostgresExtension:vector", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp without time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "OriginalText",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Vector>(
|
||||||
|
name: "Vector",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
type: "vector(1536)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "CompletedDate",
|
||||||
|
table: "QuizResults",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp without time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "QuizResults",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "LastReadDate",
|
||||||
|
table: "Ebooks",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp without time zone",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "AddedDate",
|
||||||
|
table: "Ebooks",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp without time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "Ebooks",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "uuid");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "DisplayName",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "LastAiActionDate",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "LastReadAt",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LastReadPageId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "character varying(255)",
|
||||||
|
maxLength: 255,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "KnowledgeUnits",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
SourceId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
Version = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Content = table.Column<string>(type: "text", nullable: false),
|
||||||
|
MetadataJson = table.Column<string>(type: "text", nullable: true),
|
||||||
|
TenantId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
Vector = table.Column<Vector>(type: "vector(768)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_KnowledgeUnits", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SubscriptionPlans",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
PlanName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
AITokenLimit = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
MonthlyPrice = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
StripeProductId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SubscriptionPlans", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "KnowledgeUnitLinks",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
SourceUnitId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
TargetUnitId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
RelationType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_KnowledgeUnitLinks", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_KnowledgeUnitLinks_KnowledgeUnits_SourceUnitId",
|
||||||
|
column: x => x.SourceUnitId,
|
||||||
|
principalTable: "KnowledgeUnits",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_KnowledgeUnitLinks_KnowledgeUnits_TargetUnitId",
|
||||||
|
column: x => x.TargetUnitId,
|
||||||
|
principalTable: "KnowledgeUnits",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "SubscriptionPlans",
|
||||||
|
columns: new[] { "Id", "AITokenLimit", "MonthlyPrice", "PlanName", "StripeProductId" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, 1000, 0m, "Free", "" },
|
||||||
|
{ 2, 10000, 9.99m, "Basic", "prod_basic_placeholder" },
|
||||||
|
{ 3, 50000, 19.99m, "Pro", "prod_pro_placeholder" },
|
||||||
|
{ 4, 500000, 99.99m, "Enterprise", "prod_enterprise_placeholder" }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SemanticKnowledgeCache_TenantId",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_QuizResults_TenantId",
|
||||||
|
table: "QuizResults",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Ebooks_TenantId",
|
||||||
|
table: "Ebooks",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AspNetUsers_SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "SubscriptionPlanId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AspNetUsers_TenantId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KnowledgeUnitLinks_SourceUnitId",
|
||||||
|
table: "KnowledgeUnitLinks",
|
||||||
|
column: "SourceUnitId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KnowledgeUnitLinks_TargetUnitId",
|
||||||
|
table: "KnowledgeUnitLinks",
|
||||||
|
column: "TargetUnitId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KnowledgeUnits_SourceId",
|
||||||
|
table: "KnowledgeUnits",
|
||||||
|
column: "SourceId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KnowledgeUnits_TenantId",
|
||||||
|
table: "KnowledgeUnits",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SubscriptionPlans_PlanName",
|
||||||
|
table: "SubscriptionPlans",
|
||||||
|
column: "PlanName",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AspNetUsers_SubscriptionPlans_SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "SubscriptionPlanId",
|
||||||
|
principalTable: "SubscriptionPlans",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AspNetUsers_SubscriptionPlans_SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "KnowledgeUnitLinks");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SubscriptionPlans");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "KnowledgeUnits");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_SemanticKnowledgeCache_TenantId",
|
||||||
|
table: "SemanticKnowledgeCache");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_QuizResults_TenantId",
|
||||||
|
table: "QuizResults");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Ebooks_TenantId",
|
||||||
|
table: "Ebooks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AspNetUsers_SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AspNetUsers_TenantId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OriginalText",
|
||||||
|
table: "SemanticKnowledgeCache");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "SemanticKnowledgeCache");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Vector",
|
||||||
|
table: "SemanticKnowledgeCache");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "QuizResults");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "Ebooks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DisplayName",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastAiActionDate",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastReadAt",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastReadPageId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SubscriptionPlanId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "SemanticKnowledgeCache",
|
||||||
|
type: "timestamp without time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp with time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "CompletedDate",
|
||||||
|
table: "QuizResults",
|
||||||
|
type: "timestamp without time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp with time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "LastReadDate",
|
||||||
|
table: "Ebooks",
|
||||||
|
type: "timestamp without time zone",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp with time zone",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<DateTime>(
|
||||||
|
name: "AddedDate",
|
||||||
|
table: "Ebooks",
|
||||||
|
type: "timestamp without time zone",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(DateTime),
|
||||||
|
oldType: "timestamp with time zone");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "TenantId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(128)",
|
||||||
|
oldMaxLength: 128);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CurrentPlan",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
|||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using NexusReader.Infrastructure.Persistence;
|
using NexusReader.Infrastructure.Persistence;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ namespace NexusReader.Infrastructure.Migrations
|
|||||||
.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 +163,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 +178,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 +196,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 +299,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 +310,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 +346,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 +372,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 +386,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 +409,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 +423,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 +434,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 +589,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 +630,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");
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||||
<PackageReference Include="Microsoft.ML.Tokenizers.Data.Cl100kBase" Version="2.0.0" />
|
<PackageReference Include="Microsoft.ML.Tokenizers.Data.Cl100kBase" Version="2.0.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||||
<PackageReference Include="Polly" Version="8.6.6" />
|
<PackageReference Include="Polly" Version="8.6.6" />
|
||||||
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
||||||
<PackageReference Include="Stripe.net" Version="51.1.0" />
|
<PackageReference Include="Stripe.net" Version="51.1.0" />
|
||||||
|
|||||||
@@ -16,25 +16,41 @@ public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
|
|||||||
public DbSet<KnowledgeUnitLink> KnowledgeUnitLinks => Set<KnowledgeUnitLink>();
|
public DbSet<KnowledgeUnitLink> KnowledgeUnitLinks => Set<KnowledgeUnitLink>();
|
||||||
public DbSet<Ebook> Ebooks => Set<Ebook>();
|
public DbSet<Ebook> Ebooks => Set<Ebook>();
|
||||||
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
||||||
|
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.HasPostgresExtension("pgvector");
|
modelBuilder.HasPostgresExtension("vector");
|
||||||
|
|
||||||
modelBuilder.Entity<NexusUser>(entity =>
|
modelBuilder.Entity<NexusUser>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
|
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
|
||||||
entity.Property(u => u.LastReadAt).IsRequired(false);
|
entity.Property(u => u.LastReadAt).IsRequired(false);
|
||||||
|
entity.HasIndex(u => u.TenantId);
|
||||||
|
|
||||||
|
entity.HasOne(u => u.SubscriptionPlan)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(u => u.SubscriptionPlanId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
|
||||||
|
entity.Property(u => u.SubscriptionPlanId)
|
||||||
|
.HasDefaultValue(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(p => p.PlanName).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
|
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(e => e.ContentHash);
|
entity.HasKey(e => e.ContentHash);
|
||||||
entity.HasIndex(e => e.ContentHash).IsUnique();
|
entity.HasIndex(e => e.ContentHash).IsUnique();
|
||||||
entity.HasIndex(e => e.TenantId);
|
entity.HasIndex(e => e.TenantId);
|
||||||
entity.Property(e => e.Vector).HasColumnType("vector(1536)"); // Standard for many models
|
entity.Property(e => e.Vector).HasColumnType("vector(1536)");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<KnowledgeUnit>(entity =>
|
modelBuilder.Entity<KnowledgeUnit>(entity =>
|
||||||
@@ -42,7 +58,7 @@ public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
|
|||||||
entity.HasKey(e => e.Id);
|
entity.HasKey(e => e.Id);
|
||||||
entity.HasIndex(e => e.TenantId);
|
entity.HasIndex(e => e.TenantId);
|
||||||
entity.HasIndex(e => e.SourceId);
|
entity.HasIndex(e => e.SourceId);
|
||||||
entity.Property(e => e.Vector).HasColumnType("vector(768)"); // text-embedding-004
|
entity.Property(e => e.Vector).HasColumnType("vector(768)");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<KnowledgeUnitLink>(entity =>
|
modelBuilder.Entity<KnowledgeUnitLink>(entity =>
|
||||||
@@ -65,6 +81,8 @@ public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
|
|||||||
.WithMany(u => u.Ebooks)
|
.WithMany(u => u.Ebooks)
|
||||||
.HasForeignKey(e => e.UserId)
|
.HasForeignKey(e => e.UserId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.TenantId);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<QuizResult>(entity =>
|
modelBuilder.Entity<QuizResult>(entity =>
|
||||||
@@ -73,6 +91,16 @@ public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
|
|||||||
.WithMany(u => u.QuizResults)
|
.WithMany(u => u.QuizResults)
|
||||||
.HasForeignKey(e => e.UserId)
|
.HasForeignKey(e => e.UserId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.TenantId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Seed Subscription Plans with deterministic IDs
|
||||||
|
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
||||||
|
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 1000, MonthlyPrice = 0m, StripeProductId = "" },
|
||||||
|
new SubscriptionPlan { Id = 2, PlanName = SubscriptionPlan.BasicName, AITokenLimit = 10000, MonthlyPrice = 9.99m, StripeProductId = "prod_basic_placeholder" },
|
||||||
|
new SubscriptionPlan { Id = 3, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, MonthlyPrice = 19.99m, StripeProductId = "prod_pro_placeholder" },
|
||||||
|
new SubscriptionPlan { Id = 4, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 500000, MonthlyPrice = 99.99m, StripeProductId = "prod_enterprise_placeholder" }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Pgvector.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||||
|
{
|
||||||
|
public AppDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development";
|
||||||
|
|
||||||
|
// Try to find the Web project directory by looking for the solution root
|
||||||
|
var currentDir = new DirectoryInfo(Directory.GetCurrentDirectory());
|
||||||
|
while (currentDir != null && !File.Exists(Path.Combine(currentDir.FullName, "NexusReader.slnx")))
|
||||||
|
{
|
||||||
|
currentDir = currentDir.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
var basePath = currentDir != null
|
||||||
|
? Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New")
|
||||||
|
: Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(basePath)
|
||||||
|
.AddJsonFile("appsettings.json", optional: true)
|
||||||
|
.AddJsonFile($"appsettings.{environment}.json", optional: true)
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
|
||||||
|
var connectionString = configuration.GetConnectionString("PostgresConnection");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(connectionString))
|
||||||
|
{
|
||||||
|
// For design time, if no PG connection is found, we might be using Sqlite or just testing
|
||||||
|
connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres";
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsBuilder.UseNpgsql(connectionString, x => x.UseVector());
|
||||||
|
|
||||||
|
return new AppDbContext(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ using NexusReader.Domain.Entities;
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Persistence;
|
namespace NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
@@ -14,11 +16,25 @@ public static class DbInitializer
|
|||||||
using var scope = serviceProvider.CreateScope();
|
using var scope = serviceProvider.CreateScope();
|
||||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<NexusUser>>();
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<NexusUser>>();
|
||||||
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Console.WriteLine("[Seeder] Starting database seeding...");
|
Console.WriteLine("[Seeder] Starting database seeding...");
|
||||||
|
|
||||||
|
// Seed Subscription Plans
|
||||||
|
if (!dbContext.SubscriptionPlans.Any())
|
||||||
|
{
|
||||||
|
dbContext.SubscriptionPlans.AddRange(new List<SubscriptionPlan>
|
||||||
|
{
|
||||||
|
new SubscriptionPlan { Id = SubscriptionPlan.FreeId, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, MonthlyPrice = 0, StripeProductId = "prod_Free789" },
|
||||||
|
new SubscriptionPlan { Id = SubscriptionPlan.ProId, PlanName = SubscriptionPlan.ProName, AITokenLimit = 50000, MonthlyPrice = 19, StripeProductId = "prod_Pro123" },
|
||||||
|
new SubscriptionPlan { Id = SubscriptionPlan.EnterpriseId, PlanName = SubscriptionPlan.EnterpriseName, AITokenLimit = 500000, MonthlyPrice = 99, StripeProductId = "prod_Enterprise456" }
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
Console.WriteLine("[Seeder] Subscription plans seeded.");
|
||||||
|
}
|
||||||
|
|
||||||
// Seed Roles
|
// Seed Roles
|
||||||
string[] roleNames = { "Admin", "User" };
|
string[] roleNames = { "Admin", "User" };
|
||||||
foreach (var roleName in roleNames)
|
foreach (var roleName in roleNames)
|
||||||
@@ -42,7 +58,7 @@ public static class DbInitializer
|
|||||||
UserName = adminEmail,
|
UserName = adminEmail,
|
||||||
Email = adminEmail,
|
Email = adminEmail,
|
||||||
EmailConfirmed = true,
|
EmailConfirmed = true,
|
||||||
CurrentPlan = "Enterprise",
|
SubscriptionPlanId = SubscriptionPlan.EnterpriseId,
|
||||||
AITokenLimit = 1000000,
|
AITokenLimit = 1000000,
|
||||||
TenantId = Guid.NewGuid().ToString()
|
TenantId = Guid.NewGuid().ToString()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,26 +37,29 @@ public class BillingService : IBillingService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string targetPlanName = SubscriptionPlan.FreeName;
|
||||||
|
int tokenLimit = 1000;
|
||||||
|
|
||||||
if (stripeProductId == _stripeSettings.ProProductId)
|
if (stripeProductId == _stripeSettings.ProProductId)
|
||||||
{
|
{
|
||||||
user.CurrentPlan = "Pro";
|
targetPlanName = SubscriptionPlan.ProName;
|
||||||
user.AITokenLimit = 50000;
|
tokenLimit = 50000;
|
||||||
}
|
}
|
||||||
else if (stripeProductId == _stripeSettings.BasicProductId)
|
else if (stripeProductId == _stripeSettings.BasicProductId)
|
||||||
{
|
{
|
||||||
user.CurrentPlan = "Basic";
|
targetPlanName = SubscriptionPlan.BasicName;
|
||||||
user.AITokenLimit = 10000;
|
tokenLimit = 10000;
|
||||||
}
|
}
|
||||||
else if (stripeProductId == _stripeSettings.FreeProductId || string.IsNullOrEmpty(stripeProductId))
|
else if (!string.IsNullOrEmpty(stripeProductId) && stripeProductId != _stripeSettings.FreeProductId)
|
||||||
{
|
|
||||||
user.CurrentPlan = "Free";
|
|
||||||
user.AITokenLimit = 1000;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
|
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
|
||||||
user.CurrentPlan = "Free";
|
}
|
||||||
user.AITokenLimit = 1000;
|
|
||||||
|
var plan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
|
||||||
|
if (plan != null)
|
||||||
|
{
|
||||||
|
user.SubscriptionPlanId = plan.Id;
|
||||||
|
user.AITokenLimit = tokenLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _userManager.UpdateAsync(user);
|
var result = await _userManager.UpdateAsync(user);
|
||||||
@@ -79,8 +82,12 @@ public class BillingService : IBillingService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
user.CurrentPlan = "Free";
|
var freePlan = await _dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
|
||||||
user.AITokenLimit = 1000; // Reset to free limit
|
if (freePlan != null)
|
||||||
|
{
|
||||||
|
user.SubscriptionPlanId = freePlan.Id;
|
||||||
|
user.AITokenLimit = freePlan.AITokenLimit;
|
||||||
|
}
|
||||||
|
|
||||||
var result = await _userManager.UpdateAsync(user);
|
var result = await _userManager.UpdateAsync(user);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
|
|||||||
@@ -41,7 +41,20 @@ public class EpubService : IEpubService
|
|||||||
return Result.Fail($"EPUB file not found. Checked {searchPaths.Count} locations, including: {string.Join(", ", searchPaths.Take(3))}");
|
return Result.Fail($"EPUB file not found. Checked {searchPaths.Count} locations, including: {string.Join(", ", searchPaths.Take(3))}");
|
||||||
}
|
}
|
||||||
|
|
||||||
EpubBook book = await EpubReader.ReadBookAsync(fullPath);
|
if (!File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
EpubBook book;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
book = await EpubReader.ReadBookAsync(fullPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error($"Failed to parse EPUB file. It might be corrupted or in use. Path: {fullPath}").CausedBy(ex));
|
||||||
|
}
|
||||||
var blocks = new List<ContentBlock>();
|
var blocks = new List<ContentBlock>();
|
||||||
int totalWordCount = 0;
|
int totalWordCount = 0;
|
||||||
int blockCounter = 0;
|
int blockCounter = 0;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using Polly;
|
|||||||
using Polly.Registry;
|
using Polly.Registry;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NexusReader.Infrastructure.Configuration;
|
using NexusReader.Infrastructure.Configuration;
|
||||||
|
using Pgvector;
|
||||||
using Pgvector.EntityFrameworkCore;
|
using Pgvector.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
namespace NexusReader.Infrastructure.Services;
|
||||||
@@ -20,7 +21,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
{
|
{
|
||||||
private readonly IChatClient _chatClient;
|
private readonly IChatClient _chatClient;
|
||||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||||
private readonly AppDbContext _dbContext;
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
private readonly ResiliencePipeline _retryPipeline;
|
private readonly ResiliencePipeline _retryPipeline;
|
||||||
private readonly AiSettings _settings;
|
private readonly AiSettings _settings;
|
||||||
private readonly Tokenizer _tokenizer;
|
private readonly Tokenizer _tokenizer;
|
||||||
@@ -29,13 +30,13 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
public KnowledgeService(
|
public KnowledgeService(
|
||||||
IChatClient chatClient,
|
IChatClient chatClient,
|
||||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
||||||
AppDbContext dbContext,
|
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||||
ResiliencePipelineProvider<string> pipelineProvider,
|
ResiliencePipelineProvider<string> pipelineProvider,
|
||||||
IOptions<AiSettings> settings)
|
IOptions<AiSettings> settings)
|
||||||
{
|
{
|
||||||
_chatClient = chatClient;
|
_chatClient = chatClient;
|
||||||
_embeddingGenerator = embeddingGenerator;
|
_embeddingGenerator = embeddingGenerator;
|
||||||
_dbContext = dbContext;
|
_dbContextFactory = dbContextFactory;
|
||||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
||||||
@@ -63,40 +64,30 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken);
|
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string cacheSuffix, CancellationToken cancellationToken)
|
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string traceType, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text)) return Result.Fail("Input text is empty.");
|
||||||
{
|
|
||||||
return Result.Fail("Input text is empty.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine($"[KnowledgeService] Starting extraction ({cacheSuffix}) for text sample: {text.Substring(0, Math.Min(text.Length, 50))}...");
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var normalizedText = text.Trim();
|
||||||
var normalizedText = ContentHasher.Normalize(text);
|
var hash = ContentHasher.ComputeHash(normalizedText);
|
||||||
|
|
||||||
var tokenCount = EstimateTokenCount(normalizedText);
|
|
||||||
if (tokenCount > _settings.MaxInputTokens)
|
|
||||||
{
|
|
||||||
return Result.Fail($"Input exceeds maximum token limit. Estimated tokens: {tokenCount}, limit: {_settings.MaxInputTokens}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var hash = ContentHasher.ComputeHash(normalizedText) + "_" + cacheSuffix;
|
|
||||||
|
|
||||||
// 1. Check Cache
|
// 1. Check Cache
|
||||||
var cached = await _dbContext.SemanticKnowledgeCache
|
var cached = await dbContext.SemanticKnowledgeCache
|
||||||
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId && c.PromptVersion == PromptVersion, cancellationToken);
|
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId, cancellationToken);
|
||||||
|
|
||||||
if (cached != null)
|
if (cached != null && cached.PromptVersion == PromptVersion)
|
||||||
{
|
{
|
||||||
|
Console.WriteLine($"[KnowledgeService] Cache Hit for {traceType} ({hash})");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
if (packet != null) return Result.Ok(packet);
|
if (packet != null) return Result.Ok(packet);
|
||||||
}
|
}
|
||||||
catch { }
|
catch { /* fallback to regen */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Call AI Client
|
Console.WriteLine($"[KnowledgeService] Cache Miss for {traceType} ({hash}). Requesting AI...");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var options = new ChatOptions
|
var options = new ChatOptions
|
||||||
@@ -147,26 +138,23 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
ModelId = _settings.Model,
|
ModelId = _settings.Model,
|
||||||
PromptVersion = PromptVersion,
|
PromptVersion = PromptVersion,
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
Vector = vector,
|
Vector = vector != null ? new Vector(vector) : null,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cached == null) _dbContext.SemanticKnowledgeCache.Add(cacheEntry);
|
if (cached == null) dbContext.SemanticKnowledgeCache.Add(cacheEntry);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
cached.JsonData = jsonResponse;
|
cached.JsonData = jsonResponse;
|
||||||
cached.OriginalText = normalizedText;
|
cached.OriginalText = normalizedText;
|
||||||
cached.Vector = vector;
|
cached.Vector = vector != null ? new Vector(vector) : null;
|
||||||
cached.CreatedAt = DateTime.UtcNow;
|
cached.CreatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Process KM-RAG Units and Links if present
|
// 5. Process structured KnowledgeUnits (Graph Expansion)
|
||||||
if (knowledgePacket.Units.Any())
|
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, dbContext, cancellationToken);
|
||||||
{
|
|
||||||
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
return Result.Ok(knowledgePacket);
|
return Result.Ok(knowledgePacket);
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
@@ -181,39 +169,70 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, CancellationToken cancellationToken)
|
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, AppDbContext dbContext, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var unitIds = packet.Units.Select(u => u.Id).ToList();
|
||||||
|
var linkSourceIds = packet.Links.Select(l => l.Source).ToList();
|
||||||
|
var linkTargetIds = packet.Links.Select(l => l.Target).ToList();
|
||||||
|
|
||||||
|
var allCandidateIds = unitIds.Concat(linkSourceIds).Concat(linkTargetIds).Distinct().ToList();
|
||||||
|
|
||||||
|
// Single batch query to find existing units
|
||||||
|
var existingUnits = await dbContext.KnowledgeUnits
|
||||||
|
.Where(u => allCandidateIds.Contains(u.Id))
|
||||||
|
.ToDictionaryAsync(u => u.Id, cancellationToken);
|
||||||
|
|
||||||
|
var processedUnitIds = new HashSet<string>();
|
||||||
|
|
||||||
foreach (var unitDto in packet.Units)
|
foreach (var unitDto in packet.Units)
|
||||||
{
|
{
|
||||||
var unitId = unitDto.Id;
|
var unitId = unitDto.Id;
|
||||||
var existing = await _dbContext.KnowledgeUnits.FindAsync(new object[] { unitId }, cancellationToken);
|
existingUnits.TryGetValue(unitId, out var unit);
|
||||||
|
|
||||||
|
if (unit == null)
|
||||||
|
{
|
||||||
|
unit = new KnowledgeUnit { Id = unitId, TenantId = tenantId };
|
||||||
|
dbContext.KnowledgeUnits.Add(unit);
|
||||||
|
existingUnits[unitId] = unit;
|
||||||
|
}
|
||||||
|
|
||||||
var unit = existing ?? new KnowledgeUnit { Id = unitId, TenantId = tenantId };
|
|
||||||
unit.Type = Enum.TryParse<NexusReader.Domain.Enums.KnowledgeUnitType>(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet;
|
unit.Type = Enum.TryParse<NexusReader.Domain.Enums.KnowledgeUnitType>(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet;
|
||||||
unit.Content = unitDto.Content;
|
unit.Content = unitDto.Content;
|
||||||
unit.SourceId = "extracted";
|
unit.SourceId = "extracted";
|
||||||
unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata);
|
unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata);
|
||||||
|
|
||||||
// Generate unit-specific embedding for granular retrieval
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var emb = await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, cancellationToken: cancellationToken);
|
var emb = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
unit.Vector = emb.First().Vector.ToArray();
|
await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, cancellationToken: ct), cancellationToken);
|
||||||
|
unit.Vector = new Vector(emb.First().Vector.ToArray());
|
||||||
}
|
}
|
||||||
catch { /* Ignore embedding errors for now */ }
|
catch { /* Ignore embedding errors for now */ }
|
||||||
|
|
||||||
if (existing == null) _dbContext.KnowledgeUnits.Add(unit);
|
processedUnitIds.Add(unit.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var linkDto in packet.Links)
|
foreach (var linkDto in packet.Links)
|
||||||
{
|
{
|
||||||
|
var sourceExists = processedUnitIds.Contains(linkDto.Source) || existingUnits.ContainsKey(linkDto.Source);
|
||||||
|
var targetExists = processedUnitIds.Contains(linkDto.Target) || existingUnits.ContainsKey(linkDto.Target);
|
||||||
|
|
||||||
|
if (sourceExists && targetExists)
|
||||||
|
{
|
||||||
|
// Check if link already exists to avoid duplicates if necessary
|
||||||
|
// For now, assume we can add them or they are new in this session
|
||||||
var link = new KnowledgeUnitLink
|
var link = new KnowledgeUnitLink
|
||||||
{
|
{
|
||||||
SourceUnitId = linkDto.Source,
|
SourceUnitId = linkDto.Source,
|
||||||
TargetUnitId = linkDto.Target,
|
TargetUnitId = linkDto.Target,
|
||||||
RelationType = linkDto.Relation
|
RelationType = linkDto.Relation
|
||||||
};
|
};
|
||||||
_dbContext.KnowledgeUnitLinks.Add(link);
|
dbContext.KnowledgeUnitLinks.Add(link);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[KnowledgeService] WARNING: Skipping invalid link {linkDto.Source} -> {linkDto.Target} (Missing units).");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,30 +276,21 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
|
|
||||||
public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default)
|
public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(query)) return Result.Fail("Query is empty.");
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Generate embedding for query
|
var queryEmbedding = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
|
||||||
await _embeddingGenerator.GenerateAsync(new[] { query }, cancellationToken: ct), cancellationToken);
|
await _embeddingGenerator.GenerateAsync(new[] { query }, cancellationToken: ct), cancellationToken);
|
||||||
var queryVector = embeddingResponse.First().Vector.ToArray();
|
var queryVector = new Vector(queryEmbedding.First().Vector.ToArray());
|
||||||
|
|
||||||
// 2. Search using pgvector
|
var relevantUnits = await dbContext.KnowledgeUnits
|
||||||
var results = await _dbContext.SemanticKnowledgeCache
|
.Where(u => u.TenantId == tenantId)
|
||||||
.AsNoTracking()
|
.OrderBy(u => u.Vector!.L2Distance(queryVector))
|
||||||
.Where(x => (x.TenantId == tenantId || x.TenantId == "global") && x.Vector != null)
|
|
||||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
|
||||||
.Take(5)
|
.Take(5)
|
||||||
.Select(x => new RelevantContext
|
.Select(u => new RelevantContext { Text = u.Content, Confidence = 1.0 })
|
||||||
{
|
|
||||||
Text = x.OriginalText,
|
|
||||||
SourceId = x.ContentHash,
|
|
||||||
Confidence = 1 - x.Vector!.CosineDistance(queryVector)
|
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Ok(results);
|
return Result.Ok(relevantUnits);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -290,16 +300,17 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
|
|
||||||
public async Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
|
public async Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Console.WriteLine("[KnowledgeService] Clearing SemanticKnowledgeCache...");
|
await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken);
|
||||||
_dbContext.SemanticKnowledgeCache.RemoveRange(_dbContext.SemanticKnowledgeCache);
|
await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken);
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken);
|
||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail($"Failed to clear cache: {ex.Message}");
|
return Result.Fail(new Error("Failed to clear knowledge cache").CausedBy(ex));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
|
Coordinator.Clear();
|
||||||
ThemeService.OnThemeChanged += StateHasChanged;
|
ThemeService.OnThemeChanged += StateHasChanged;
|
||||||
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ public sealed class KnowledgeCoordinator : IDisposable
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_graphService.Clear();
|
||||||
|
_quizService.SetQuiz(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_interactionService.OnNodeSelected -= HandleNodeSelected;
|
_interactionService.OnNodeSelected -= HandleNodeSelected;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using NexusReader.Web.Components;
|
|||||||
using NexusReader.Application;
|
using NexusReader.Application;
|
||||||
using NexusReader.Infrastructure;
|
using NexusReader.Infrastructure;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using NexusReader.Application.DTOs.User;
|
||||||
using NexusReader.Web.Client.Services;
|
using NexusReader.Web.Client.Services;
|
||||||
using NexusReader.UI.Shared.Services;
|
using NexusReader.UI.Shared.Services;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
@@ -67,7 +68,7 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
|||||||
// Authorization Policies
|
// Authorization Policies
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
|
||||||
builder.Services.AddAuthorizationBuilder()
|
builder.Services.AddAuthorizationBuilder()
|
||||||
.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise"))
|
.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", SubscriptionPlan.ProName, SubscriptionPlan.EnterpriseName))
|
||||||
.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
|
.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
|
||||||
|
|
||||||
// Billing & Stripe
|
// Billing & Stripe
|
||||||
@@ -245,8 +246,6 @@ knowledgeApi.MapPost("/verify-groundedness", async (GroundednessRequest request,
|
|||||||
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
|
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
|
knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
|
||||||
{
|
{
|
||||||
var result = await knowledgeService.ClearCacheAsync();
|
var result = await knowledgeService.ClearCacheAsync();
|
||||||
@@ -256,8 +255,13 @@ knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
|
|||||||
return Results.BadRequest(errorMsg);
|
return Results.BadRequest(errorMsg);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager<NexusUser> userManager, IConfiguration configuration) =>
|
app.MapPost("/api/StripeWebhook", async (
|
||||||
|
HttpContext context,
|
||||||
|
UserManager<NexusUser> userManager,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||||
{
|
{
|
||||||
|
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||||
var json = await new StreamReader(context.Request.Body).ReadToEndAsync();
|
var json = await new StreamReader(context.Request.Body).ReadToEndAsync();
|
||||||
var webhookSecret = configuration["Stripe:WebhookSecret"] ?? "";
|
var webhookSecret = configuration["Stripe:WebhookSecret"] ?? "";
|
||||||
|
|
||||||
@@ -273,20 +277,19 @@ app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager<NexusU
|
|||||||
{
|
{
|
||||||
case EventTypes.CheckoutSessionCompleted:
|
case EventTypes.CheckoutSessionCompleted:
|
||||||
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
|
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
|
||||||
await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata, userManager);
|
await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata, userManager, dbContext);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EventTypes.CustomerSubscriptionUpdated:
|
case EventTypes.CustomerSubscriptionUpdated:
|
||||||
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
|
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
|
||||||
await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager);
|
await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager, dbContext);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EventTypes.CustomerSubscriptionDeleted:
|
case EventTypes.CustomerSubscriptionDeleted:
|
||||||
var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription;
|
var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription;
|
||||||
await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager);
|
await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager, dbContext);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok();
|
return Results.Ok();
|
||||||
}
|
}
|
||||||
catch (StripeException e)
|
catch (StripeException e)
|
||||||
@@ -295,36 +298,43 @@ app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager<NexusU
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async Task HandleSubscriptionSuccess(string? email, Dictionary<string, string>? metadata, UserManager<NexusUser> userManager)
|
async Task HandleSubscriptionSuccess(
|
||||||
|
string? email,
|
||||||
|
Dictionary<string, string>? metadata,
|
||||||
|
UserManager<NexusUser> userManager,
|
||||||
|
AppDbContext dbContext)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(email)) return;
|
if (string.IsNullOrEmpty(email)) return;
|
||||||
|
|
||||||
var user = await userManager.FindByEmailAsync(email);
|
var user = await userManager.FindByEmailAsync(email);
|
||||||
if (user != null)
|
if (user != null)
|
||||||
{
|
{
|
||||||
var plan = metadata?.GetValueOrDefault("Plan") ?? "Pro";
|
var planName = metadata?.GetValueOrDefault("Plan") ?? SubscriptionPlan.ProName;
|
||||||
|
var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == planName);
|
||||||
|
|
||||||
user.CurrentPlan = plan;
|
if (plan != null)
|
||||||
user.AITokenLimit = plan.ToLower() switch
|
|
||||||
{
|
{
|
||||||
"pro" => 50000,
|
user.SubscriptionPlanId = plan.Id;
|
||||||
"enterprise" => 500000,
|
user.AITokenLimit = plan.AITokenLimit;
|
||||||
_ => 10000 // default for unknown or free
|
}
|
||||||
};
|
|
||||||
|
|
||||||
await userManager.UpdateAsync(user);
|
await userManager.UpdateAsync(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task HandleSubscriptionCancellation(string? email, UserManager<NexusUser> userManager)
|
async Task HandleSubscriptionCancellation(
|
||||||
|
string? email,
|
||||||
|
UserManager<NexusUser> userManager,
|
||||||
|
AppDbContext dbContext)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(email)) return;
|
if (string.IsNullOrEmpty(email)) return;
|
||||||
|
|
||||||
var user = await userManager.FindByEmailAsync(email);
|
var user = await userManager.FindByEmailAsync(email);
|
||||||
if (user != null)
|
if (user != null)
|
||||||
{
|
{
|
||||||
user.CurrentPlan = "Free";
|
var freePlan = await dbContext.SubscriptionPlans.FindAsync(SubscriptionPlan.FreeId);
|
||||||
user.AITokenLimit = 5000; // Free tier limit
|
user.SubscriptionPlanId = SubscriptionPlan.FreeId;
|
||||||
|
user.AITokenLimit = freePlan?.AITokenLimit ?? 5000;
|
||||||
await userManager.UpdateAsync(user);
|
await userManager.UpdateAsync(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,7 +369,6 @@ app.MapGet("/identity/callback/google", async (
|
|||||||
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
|
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
|
||||||
if (email != null)
|
if (email != null)
|
||||||
{
|
{
|
||||||
// TODO: REV-5 - Consider redirecting to Terms of Service / Onboarding before final provisioning
|
|
||||||
var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true };
|
var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true };
|
||||||
var createResult = await userManager.CreateAsync(user);
|
var createResult = await userManager.CreateAsync(user);
|
||||||
if (createResult.Succeeded)
|
if (createResult.Succeeded)
|
||||||
@@ -373,22 +382,32 @@ app.MapGet("/identity/callback/google", async (
|
|||||||
return Results.Redirect("/account/login?error=ProvisioningFailed");
|
return Results.Redirect("/account/login?error=ProvisioningFailed");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager, AppDbContext dbContext) =>
|
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager, IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||||
{
|
{
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
if (userId == null) return Results.Unauthorized();
|
if (userId == null) return Results.Unauthorized();
|
||||||
|
|
||||||
|
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var profile = await dbContext.Users
|
var profile = await dbContext.Users
|
||||||
.Where(u => u.Id == userId)
|
.Where(u => u.Id == userId)
|
||||||
.Select(u => new
|
.Select(u => new UserProfileDto
|
||||||
{
|
{
|
||||||
u.Email,
|
Email = u.Email ?? string.Empty,
|
||||||
u.AITokenLimit,
|
AITokensUsed = u.AITokensUsed,
|
||||||
u.AITokensUsed,
|
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
||||||
u.CurrentPlan,
|
{
|
||||||
u.TenantId,
|
Id = u.SubscriptionPlan.Id,
|
||||||
AverageQuizScore = u.QuizResults.Any() ? (int?)u.QuizResults.Average(q => q.Percentage) ?? 0 : 0,
|
Name = u.SubscriptionPlan.PlanName,
|
||||||
LastReadBookTitle = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => e.Title).FirstOrDefault() ?? "None"
|
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
|
||||||
|
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
|
||||||
|
} : new SubscriptionPlanDto(),
|
||||||
|
AverageQuizScore = u.QuizResults.Any() ? (int)u.QuizResults.Average(q => q.Percentage) : 0,
|
||||||
|
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
|
||||||
|
{
|
||||||
|
Id = e.Id,
|
||||||
|
Title = e.Title
|
||||||
|
}).FirstOrDefault()
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user