Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39717725ec | |||
| 9d396570aa | |||
| d78abd0c4d | |||
| 97c1c309b1 | |||
| 5740d9126a | |||
| f902073bcb | |||
| 0a3ca77d46 | |||
| 37bec89484 | |||
| cb4b7d0052 | |||
| 23acaeb705 | |||
| 711822f5de | |||
| 541e9e1fb5 | |||
| f808734768 | |||
| 5a2223a4c8 | |||
| d5c2952bec | |||
| fe5ff81c98 | |||
| f433e3c74a | |||
| 9a45a078a6 | |||
| 2e23a032d3 | |||
| 34794db209 |
@@ -0,0 +1,288 @@
|
||||
---
|
||||
name: blazor-expert
|
||||
description: Comprehensive Blazor development expertise covering Blazor Server, WebAssembly, and Hybrid apps. Use when building Blazor components, implementing state management, handling routing, JavaScript interop, forms and validation, authentication, or optimizing Blazor applications. Includes best practices, architecture patterns, and troubleshooting guidance.
|
||||
version: 2.0
|
||||
---
|
||||
|
||||
# Blazor Expert - Orchestration Hub
|
||||
|
||||
Expert-level guidance for developing applications with Blazor, Microsoft's framework for building interactive web UIs using C# instead of JavaScript.
|
||||
|
||||
## Quick Reference: When to Load Which Resource
|
||||
|
||||
| Task | Load Resource | Key Topics |
|
||||
|------|---------------|-----------|
|
||||
| **Build components, handle lifecycle events** | [components-lifecycle.md](resources/components-lifecycle.md) | Component structure, lifecycle methods, parameters, cascading values, RenderFragment composition |
|
||||
| **Manage component state, handle events** | [state-management-events.md](resources/state-management-events.md) | Local state, EventCallback, data binding, cascading state, service-based state |
|
||||
| **Configure routes, navigate between pages** | [routing-navigation.md](resources/routing-navigation.md) | Route parameters, constraints, navigation, NavLink, query strings, layouts |
|
||||
| **Build forms, validate user input** | [forms-validation.md](resources/forms-validation.md) | EditForm, input components, DataAnnotations validation, custom validators |
|
||||
| **Setup authentication & authorization** | [authentication-authorization.md](resources/authentication-authorization.md) | Auth setup, AuthorizeView, Authorize attribute, policies, claims |
|
||||
| **Optimize performance, use JavaScript interop** | [performance-advanced.md](resources/performance-advanced.md) | Rendering optimization, virtualization, JS interop, lazy loading, WASM best practices |
|
||||
|
||||
## Orchestration Protocol
|
||||
|
||||
### Phase 1: Task Analysis
|
||||
|
||||
Identify your primary objective:
|
||||
|
||||
- **UI Building** → Load components-lifecycle.md
|
||||
- **State Handling** → Load state-management-events.md
|
||||
- **Navigation** → Load routing-navigation.md
|
||||
- **Data Input** → Load forms-validation.md
|
||||
- **User Access** → Load authentication-authorization.md
|
||||
- **Speed/Efficiency** → Load performance-advanced.md
|
||||
|
||||
### Phase 2: Resource Loading
|
||||
|
||||
Open the recommended resource file(s) and search for your specific need using Ctrl+F. Each resource is organized by topic with working code examples.
|
||||
|
||||
### Phase 3: Implementation & Validation
|
||||
|
||||
- Follow code patterns from the resource
|
||||
- Adapt to your specific requirements
|
||||
- Test in appropriate hosting model (Server/WASM/Hybrid)
|
||||
- Review troubleshooting section if issues arise
|
||||
|
||||
## Blazor Hosting Models Overview
|
||||
|
||||
### Blazor Server
|
||||
|
||||
- **How**: Runs on server via SignalR
|
||||
- **Best For**: Line-of-business apps, need full .NET runtime, small download size
|
||||
- **Trade-offs**: High latency, requires connection, server resource intensive
|
||||
|
||||
### Blazor WebAssembly
|
||||
|
||||
- **How**: Runs in browser via WebAssembly
|
||||
- **Best For**: PWAs, offline apps, no server dependency, client-heavy applications
|
||||
- **Trade-offs**: Large initial download, limited .NET APIs, slower cold start
|
||||
|
||||
### Blazor Hybrid
|
||||
|
||||
- **How**: Runs in MAUI/WPF/WinForms with Blazor UI
|
||||
- **Best For**: Cross-platform desktop/mobile apps
|
||||
- **Trade-offs**: Platform-specific considerations, additional dependencies
|
||||
|
||||
**Decision**: Choose based on deployment environment, offline requirements, and server constraints.
|
||||
|
||||
## Common Implementation Workflows
|
||||
|
||||
### Scenario 1: Build a Data-Entry Component
|
||||
|
||||
1. Read [components-lifecycle.md](resources/components-lifecycle.md) - Component structure section
|
||||
2. Read [state-management-events.md](resources/state-management-events.md) - EventCallback pattern
|
||||
3. Read [forms-validation.md](resources/forms-validation.md) - EditForm component
|
||||
4. Combine: Create component with parameters → capture user input → validate → notify parent
|
||||
|
||||
### Scenario 2: Implement User Authentication & Protected Pages
|
||||
|
||||
1. Read [authentication-authorization.md](resources/authentication-authorization.md) - Setup section
|
||||
2. Read [routing-navigation.md](resources/routing-navigation.md) - Layouts section
|
||||
3. Read [authentication-authorization.md](resources/authentication-authorization.md) - AuthorizeView section
|
||||
4. Combine: Configure auth → create login page → protect routes → check auth in components
|
||||
|
||||
### Scenario 3: Build Interactive List with Search/Filter
|
||||
|
||||
1. Read [routing-navigation.md](resources/routing-navigation.md) - Query strings section
|
||||
2. Read [state-management-events.md](resources/state-management-events.md) - Data binding section
|
||||
3. Read [performance-advanced.md](resources/performance-advanced.md) - Virtualization section
|
||||
4. Combine: Capture search input → update URL query → fetch filtered data → virtualize if large
|
||||
|
||||
### Scenario 4: Optimize Performance of Existing App
|
||||
|
||||
1. Read [performance-advanced.md](resources/performance-advanced.md) - All sections
|
||||
2. Identify bottlenecks:
|
||||
- Unnecessary renders? → ShouldRender override, @key directive
|
||||
- Large lists? → Virtualization
|
||||
- JS latency? → Module isolation pattern
|
||||
3. Apply targeted optimizations from resource
|
||||
|
||||
## Key Blazor Concepts
|
||||
|
||||
### Component Architecture
|
||||
|
||||
- **Components**: Self-contained UI units with optional logic
|
||||
- **Parameters**: Inputs to components, enable reusability
|
||||
- **Cascading Values**: Share state with descendants without explicit parameters
|
||||
- **Events**: Child-to-parent communication via EventCallback
|
||||
- **Layouts**: Parent wrapper for consistent page structure
|
||||
|
||||
### State Management
|
||||
|
||||
- **Local State**: Component-specific fields and properties
|
||||
- **Cascading Values**: Share state to descendants
|
||||
- **Services**: Application-wide state via dependency injection
|
||||
- **Event Binding**: React to user interactions
|
||||
- **Data Binding**: Two-way synchronization with UI
|
||||
|
||||
### Routing & Navigation
|
||||
|
||||
- **@page Directive**: Make component routable
|
||||
- **Route Parameters**: Pass data via URL (`{id:int}`)
|
||||
- **Navigation**: Programmatic navigation via NavigationManager
|
||||
- **NavLink**: UI component that highlights active route
|
||||
- **Layouts**: Wrap pages with common structure
|
||||
|
||||
### Forms & Validation
|
||||
|
||||
- **EditForm**: Form component with validation support
|
||||
- **Input Components**: Typed controls (InputText, InputNumber, etc.)
|
||||
- **Validators**: DataAnnotations attributes or custom logic
|
||||
- **EventCallback**: Notify parent of form changes
|
||||
- **Messages**: Display validation errors to user
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **Claims & Roles**: Identify users and define access levels
|
||||
- **Policies**: Fine-grained authorization rules
|
||||
- **Authorize Attribute**: Protect pages from unauthorized access
|
||||
- **AuthorizeView**: Conditional rendering based on permissions
|
||||
- **AuthenticationStateProvider**: Get current user information
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
- **ShouldRender()**: Prevent unnecessary re-renders
|
||||
- **@key Directive**: Help diffing algorithm match list items
|
||||
- **Virtualization**: Render only visible items in large lists
|
||||
- **JS Interop**: Call JavaScript from C# and vice versa
|
||||
- **AOT/Trimming**: Reduce WASM download size (production)
|
||||
|
||||
## Best Practices Highlights
|
||||
|
||||
### Component Design
|
||||
|
||||
✅ **Single Responsibility** - Each component has one clear purpose
|
||||
✅ **Composition** - Use RenderFragments for flexible layouts
|
||||
✅ **Parameter Clarity** - Use descriptive names, mark required with `[EditorRequired]`
|
||||
✅ **Proper Disposal** - Implement `IDisposable` to clean up resources
|
||||
✅ **Event-Based Communication** - Use `EventCallback` for child-to-parent updates
|
||||
|
||||
### State Management
|
||||
|
||||
✅ **EventCallback Over Action** - Proper async handling
|
||||
✅ **Immutable Updates** - Create new objects/collections, don't mutate
|
||||
✅ **Service-Based State** - Use scoped services for shared state
|
||||
✅ **Unsubscribe from Events** - Prevent memory leaks in Dispose
|
||||
✅ **InvokeAsync for Background Threads** - Thread-safe state updates
|
||||
|
||||
### Routing & Navigation
|
||||
|
||||
✅ **Route Constraints** - Use `:int`, `:guid`, etc. to validate formats
|
||||
✅ **NavLink Component** - Automatic active state highlighting
|
||||
✅ **forceLoad After Logout** - Clear client-side state
|
||||
✅ **ReturnUrl Pattern** - Redirect back after login
|
||||
✅ **Query Strings** - Preserve filters/pagination across navigation
|
||||
|
||||
### Forms & Validation
|
||||
|
||||
✅ **EditForm + DataAnnotationsValidator** - Built-in validation
|
||||
✅ **ValidationMessage** - Show field-level errors
|
||||
✅ **Custom Validators** - Extend for complex rules
|
||||
✅ **Async Validation** - Check server availability before submit
|
||||
✅ **Loading State** - Disable submit button while processing
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
✅ **Server Validation** - Never trust client-side checks alone
|
||||
✅ **Policies Over Roles** - More flexible authorization rules
|
||||
✅ **Claims for Details** - Store user attributes in claims
|
||||
✅ **Cascading AuthenticationState** - Available in all components
|
||||
✅ **Error Boundaries** - Graceful error handling
|
||||
|
||||
### Performance
|
||||
|
||||
✅ **@key on Lists** - Optimize item matching
|
||||
✅ **ShouldRender Override** - Prevent unnecessary renders
|
||||
✅ **Virtualization for Large Lists** - Only render visible items
|
||||
✅ **JS Module Isolation** - Load and cache JS modules efficiently
|
||||
✅ **AOT for WASM** - Production deployments
|
||||
|
||||
## Common Troubleshooting
|
||||
|
||||
### Component Not Re-rendering
|
||||
|
||||
- **Cause**: Mutation instead of reassignment
|
||||
- **Fix**: Create new object/collection: `items = items.Append(item).ToList()`
|
||||
- **Or**: Call `StateHasChanged()` manually
|
||||
|
||||
### Parameter Not Updating
|
||||
|
||||
- **Cause**: Parent not re-rendering or same object reference
|
||||
- **Fix**: Parent must re-render, ensure new reference for objects
|
||||
- **Debug**: Check OnParametersSet is firing
|
||||
|
||||
### JS Interop Errors
|
||||
|
||||
- **Cause**: Called before script loaded or wrong function name
|
||||
- **Fix**: Use `firstRender` check, verify JS file path
|
||||
- **Pattern**: Use module isolation: `await JS.InvokeAsync("import", "./script.js")`
|
||||
|
||||
### Authentication State Not Available
|
||||
|
||||
- **Cause**: Cascading parameter not provided or timing issue
|
||||
- **Fix**: Ensure AuthenticationStateProvider configured
|
||||
- **Pattern**: Always null-check and use `await AuthStateTask!` in code block
|
||||
|
||||
### Large List Performance Issues
|
||||
|
||||
- **Cause**: Rendering all items in DOM
|
||||
- **Fix**: Use Virtualize component for 1000+ items
|
||||
- **Alternative**: Paginate with buttons/infinite scroll
|
||||
|
||||
### Blazor Server Connection Issues
|
||||
|
||||
- **Cause**: SignalR connection dropped or configuration issue
|
||||
- **Fix**: Implement reconnection UI, increase timeout
|
||||
- **Config**: Adjust `CircuitOptions.DisconnectedCircuitRetentionPeriod`
|
||||
|
||||
## Resource Files Summary
|
||||
|
||||
### components-lifecycle.md
|
||||
|
||||
Complete guide to component structure, lifecycle methods, parameters, cascading values, and composition patterns. Essential for understanding Blazor component fundamentals.
|
||||
|
||||
### state-management-events.md
|
||||
|
||||
Comprehensive coverage of local and service-based state, event handling with EventCallback, data binding patterns, and component communication. Core for interactive UI building.
|
||||
|
||||
### routing-navigation.md
|
||||
|
||||
Complete routing reference including route parameters, constraints, programmatic navigation, query strings, and layout management. Essential for multi-page apps.
|
||||
|
||||
### forms-validation.md
|
||||
|
||||
Full forms API with EditForm component, input controls, DataAnnotations validation, custom validators, and form patterns. Required for data entry scenarios.
|
||||
|
||||
### authentication-authorization.md
|
||||
|
||||
Complete auth setup for Server and WASM, AuthorizeView, policies, claims-based access control, and login/logout patterns. Necessary for secured applications.
|
||||
|
||||
### performance-advanced.md
|
||||
|
||||
Performance optimization techniques including ShouldRender, virtualization, JavaScript interop patterns, lazy loading, and WASM best practices. Vital for production apps.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
When implementing Blazor features:
|
||||
|
||||
1. **Identify Your Task** - Match against the decision table above
|
||||
2. **Load Relevant Resource** - Read the appropriate .md file
|
||||
3. **Find Code Example** - Search resource for similar implementation
|
||||
4. **Adapt to Your Context** - Modify for your specific requirements
|
||||
5. **Test Thoroughly** - Verify in your hosting model
|
||||
6. **Reference Troubleshooting** - Consult resource if issues arise
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **New to Blazor?** Start with [components-lifecycle.md](resources/components-lifecycle.md)
|
||||
- **Building Data App?** Move through: components → state → forms → validation
|
||||
- **Scaling Existing App?** Focus on [performance-advanced.md](resources/performance-advanced.md)
|
||||
- **Adding Security?** Follow [authentication-authorization.md](resources/authentication-authorization.md)
|
||||
|
||||
---
|
||||
|
||||
**Version**: 2.0 - Modular Orchestration Pattern
|
||||
**Last Updated**: December 4, 2025
|
||||
**Status**: Production Ready ✅
|
||||
@@ -0,0 +1,533 @@
|
||||
# Blazor Authentication & Authorization
|
||||
|
||||
## Authentication Setup
|
||||
|
||||
### Blazor Server Setup
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add authentication
|
||||
builder.Services
|
||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/login";
|
||||
options.LogoutPath = "/logout";
|
||||
options.AccessDeniedPath = "/unauthorized";
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorizationCore();
|
||||
|
||||
// Add Blazor Server
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddServerSideBlazor();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Add authentication middleware BEFORE MapRazorPages
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapRazorPages();
|
||||
app.MapBlazorHub();
|
||||
```
|
||||
|
||||
### Blazor WebAssembly Setup
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
builder.RootComponents.Add<App>("#app");
|
||||
|
||||
// Add authentication
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
|
||||
builder.Services.AddScoped<HttpClient>(sp =>
|
||||
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
// CustomAuthStateProvider
|
||||
public class CustomAuthStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly HttpClient httpClient;
|
||||
|
||||
public CustomAuthStateProvider(HttpClient httpClient)
|
||||
{
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await httpClient.GetJsonAsync<UserInfo>("/api/user");
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||
new Claim(ClaimTypes.Name, user.Name),
|
||||
new Claim(ClaimTypes.Email, user.Email)
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "Custom");
|
||||
return new AuthenticationState(new ClaimsPrincipal(identity));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## AuthorizeView Component
|
||||
|
||||
AuthorizeView displays content conditionally based on authorization status.
|
||||
|
||||
### Basic Authorization Check
|
||||
|
||||
```html
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<p>Hello, @context.User.Identity?.Name!</p>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<p>Please log in.</p>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
```
|
||||
|
||||
### Authorize by Role
|
||||
|
||||
```html
|
||||
<AuthorizeView Roles="Admin">
|
||||
<p>This content is only for Admins</p>
|
||||
</AuthorizeView>
|
||||
|
||||
<AuthorizeView Roles="User, Moderator">
|
||||
<p>User or Moderator content</p>
|
||||
</AuthorizeView>
|
||||
```
|
||||
|
||||
### Authorize by Policy
|
||||
|
||||
```html
|
||||
<AuthorizeView Policy="ContentEditor">
|
||||
<p>Only content editors can see this</p>
|
||||
</AuthorizeView>
|
||||
```
|
||||
|
||||
### Multiple AuthorizeView States
|
||||
|
||||
```html
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@if (context.User.IsInRole("Admin"))
|
||||
{
|
||||
<p>Admin dashboard</p>
|
||||
}
|
||||
else if (context.User.IsInRole("Editor"))
|
||||
{
|
||||
<p>Editor dashboard</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>User dashboard</p>
|
||||
}
|
||||
</Authorized>
|
||||
<Authorizing>
|
||||
<p>Checking authorization...</p>
|
||||
</Authorizing>
|
||||
<NotAuthorized>
|
||||
<p>Not authorized</p>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
```
|
||||
|
||||
### Authorize Multiple Resources
|
||||
|
||||
```html
|
||||
<AuthorizeView Context="Auth">
|
||||
<Authorized>
|
||||
<div>
|
||||
<h2>@Auth.User.Identity?.Name</h2>
|
||||
|
||||
@if (Auth.User.IsInRole("Admin"))
|
||||
{
|
||||
<a href="/admin">Admin Panel</a>
|
||||
}
|
||||
|
||||
@if (Auth.User.HasClaim("department", "engineering"))
|
||||
{
|
||||
<a href="/engineering">Engineering</a>
|
||||
}
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
```
|
||||
|
||||
## Authorize Attribute
|
||||
|
||||
Apply `[Authorize]` to pages to require authentication.
|
||||
|
||||
### Basic Page Authorization
|
||||
|
||||
```csharp
|
||||
@page "/admin"
|
||||
@attribute [Authorize]
|
||||
|
||||
<h2>Admin Page</h2>
|
||||
<p>Only authenticated users can see this.</p>
|
||||
```
|
||||
|
||||
### Role-Based Authorization
|
||||
|
||||
```csharp
|
||||
@page "/admin"
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
|
||||
<h2>Admin Panel</h2>
|
||||
<p>Only admins can access this page.</p>
|
||||
```
|
||||
|
||||
### Policy-Based Authorization
|
||||
|
||||
```csharp
|
||||
@page "/dashboard"
|
||||
@attribute [Authorize(Policy = "RequireAdminRole")]
|
||||
|
||||
<h2>Dashboard</h2>
|
||||
```
|
||||
|
||||
### Multiple Requirements
|
||||
|
||||
```csharp
|
||||
@page "/admin"
|
||||
@attribute [Authorize(Roles = "Admin, Manager")]
|
||||
@attribute [Authorize(Policy = "ActiveSubscription")]
|
||||
|
||||
<h2>Admin Dashboard</h2>
|
||||
```
|
||||
|
||||
## Authorization Policies
|
||||
|
||||
Define fine-grained authorization policies.
|
||||
|
||||
### Setup Policies
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
builder.Services.AddAuthorizationCore(options =>
|
||||
{
|
||||
options.AddPolicy("RequireAdminRole", policy =>
|
||||
policy.RequireRole("Admin"));
|
||||
|
||||
options.AddPolicy("ActiveSubscription", policy =>
|
||||
policy.Requirements.Add(new ActiveSubscriptionRequirement()));
|
||||
|
||||
options.AddPolicy("ContentEditor", policy =>
|
||||
policy.RequireClaim("department", "engineering", "content"));
|
||||
|
||||
options.AddPolicy("AdultUser", policy =>
|
||||
policy.Requirements.Add(new MinimumAgeRequirement(18)));
|
||||
});
|
||||
|
||||
// Add custom policy handler
|
||||
builder.Services.AddSingleton<IAuthorizationHandler, ActiveSubscriptionHandler>();
|
||||
```
|
||||
|
||||
### Custom Policy Handlers
|
||||
|
||||
```csharp
|
||||
public class ActiveSubscriptionRequirement : IAuthorizationRequirement { }
|
||||
|
||||
public class ActiveSubscriptionHandler : AuthorizationHandler<ActiveSubscriptionRequirement>
|
||||
{
|
||||
private readonly IUserService userService;
|
||||
|
||||
public ActiveSubscriptionHandler(IUserService userService)
|
||||
{
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
ActiveSubscriptionRequirement requirement)
|
||||
{
|
||||
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await userService.GetUserAsync(userId);
|
||||
|
||||
if (user?.SubscriptionActive == true)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Fail();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class MinimumAgeRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public int MinimumAge { get; set; }
|
||||
|
||||
public MinimumAgeRequirement(int minimumAge)
|
||||
{
|
||||
MinimumAge = minimumAge;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Accessing Authentication State
|
||||
|
||||
### In Components
|
||||
|
||||
```csharp
|
||||
@page "/user-profile"
|
||||
|
||||
@if (authState == null)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<h2>Welcome, @authState.User.Identity?.Name</h2>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Not authenticated</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private AuthenticationState? authState;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
authState = await AuthStateTask!;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Check Claims and Roles
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
private async Task CheckUserAsync()
|
||||
{
|
||||
var authState = await AuthStateTask!;
|
||||
var user = authState.User;
|
||||
|
||||
if (user.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var name = user.Identity.Name;
|
||||
var email = user.FindFirst(ClaimTypes.Email)?.Value;
|
||||
var isAdmin = user.IsInRole("Admin");
|
||||
var department = user.FindFirst("department")?.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Login/Logout Implementation
|
||||
|
||||
### Login Page
|
||||
|
||||
```csharp
|
||||
@page "/login"
|
||||
@layout BlankLayout
|
||||
|
||||
<div class="login-form">
|
||||
<h2>Login</h2>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
|
||||
<EditForm Model="@model" OnValidSubmit="@HandleLoginAsync">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="form-group">
|
||||
<label>Email:</label>
|
||||
<InputText @bind-Value="model.Email" class="form-control" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password:</label>
|
||||
<InputText @bind-Value="model.Password" type="password" class="form-control" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
private LoginModel model = new();
|
||||
private string? errorMessage;
|
||||
|
||||
private async Task HandleLoginAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await AuthService.LoginAsync(model.Email, model.Password);
|
||||
|
||||
// Update authentication state
|
||||
if (AuthStateProvider is CustomAuthStateProvider customAuth)
|
||||
{
|
||||
await customAuth.SetUserAsync(result.User);
|
||||
}
|
||||
|
||||
// Redirect to return URL or home
|
||||
var url = !string.IsNullOrEmpty(ReturnUrl) ? ReturnUrl : "/";
|
||||
Navigation.NavigateTo(url, forceLoad: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class LoginModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
```
|
||||
|
||||
### Logout Endpoint
|
||||
|
||||
```csharp
|
||||
// Pages/Logout.cshtml (in Blazor Server)
|
||||
@page "/logout"
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@inject SignInManager<IdentityUser> SignInManager
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await SignInManager.SignOutAsync();
|
||||
Navigation.NavigateTo("/");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Claims-Based Authorization
|
||||
|
||||
Working with claims for fine-grained authorization.
|
||||
|
||||
### Add Claims to User
|
||||
|
||||
```csharp
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||
new Claim(ClaimTypes.Name, user.Name),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
new Claim("department", "engineering"),
|
||||
new Claim("level", "senior")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "Custom");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
```
|
||||
|
||||
### Check Claims in Component
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
private async Task CheckDepartmentAsync()
|
||||
{
|
||||
var authState = await AuthStateTask!;
|
||||
var user = authState.User;
|
||||
|
||||
var department = user.FindFirst("department")?.Value;
|
||||
var level = user.FindFirst("level")?.Value;
|
||||
|
||||
if (department == "engineering")
|
||||
{
|
||||
// Show engineering-specific UI
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Use Cascading AuthenticationState
|
||||
|
||||
```csharp
|
||||
// App.razor - already cascades AuthenticationState by default
|
||||
<CascadingAuthenticationState>
|
||||
<Router ... />
|
||||
</CascadingAuthenticationState>
|
||||
```
|
||||
|
||||
### Always Check firstRender in OnAfterRender
|
||||
|
||||
```csharp
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Initialize only once
|
||||
authState = await AuthStateTask!;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use forceLoad for Logout
|
||||
|
||||
```csharp
|
||||
private async Task LogoutAsync()
|
||||
{
|
||||
await AuthService.LogoutAsync();
|
||||
// forceLoad clears client-side state
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
```
|
||||
|
||||
### Validate on Server
|
||||
|
||||
- Never trust client-side authorization
|
||||
- Always validate authorization on backend API
|
||||
- Check claims/roles on server methods
|
||||
|
||||
### Use ReturnUrl After Login
|
||||
|
||||
```csharp
|
||||
// Redirect back to originally-requested page
|
||||
Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [routing-navigation.md](routing-navigation.md) for route-based authorization. See [components-lifecycle.md](components-lifecycle.md) for parameter security.
|
||||
@@ -0,0 +1,550 @@
|
||||
# Blazor Components & Component Lifecycle
|
||||
|
||||
## Component Structure
|
||||
|
||||
Components are the fundamental building blocks of Blazor applications. A Blazor component is a self-contained piece of UI with an optional logic.
|
||||
|
||||
### Basic Component Syntax
|
||||
|
||||
```csharp
|
||||
@page "/example"
|
||||
@using MyApp.Services
|
||||
@inject IMyService MyService
|
||||
|
||||
<h3>@Title</h3>
|
||||
<div>@ChildContent</div>
|
||||
<button @onclick="HandleClick">Click me</button>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string Title { get; set; } = "Default";
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private void HandleClick()
|
||||
{
|
||||
// Handle button click
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key elements:
|
||||
|
||||
- **`@page` directive**: Makes component routable (optional for non-page components)
|
||||
- **`@using`**: Import namespaces
|
||||
- **`@inject`**: Dependency injection
|
||||
- **HTML markup**: Regular HTML with Blazor directives
|
||||
- **`@code` block**: C# logic including lifecycle methods
|
||||
|
||||
### Component vs Page
|
||||
|
||||
- **Page Component**: Has `@page` directive, routable via URL
|
||||
- Example: `/Counter` route
|
||||
- Located in `Pages/` folder (convention)
|
||||
|
||||
- **Reusable Component**: No `@page` directive, used by other components
|
||||
- Example: `<UserCard @bind-User="user" />`
|
||||
- Located in `Shared/` or domain-specific folder
|
||||
|
||||
## Component Lifecycle
|
||||
|
||||
### Lifecycle Sequence
|
||||
|
||||
Component lifecycle methods execute in this order:
|
||||
|
||||
```
|
||||
1. SetParametersAsync()
|
||||
↓
|
||||
2. OnInitialized() or OnInitializedAsync()
|
||||
↓
|
||||
3. OnParametersSet() or OnParametersSetAsync()
|
||||
↓
|
||||
4. ShouldRender() [decision point - skip if returns false]
|
||||
↓
|
||||
5. OnAfterRender() or OnAfterRenderAsync()
|
||||
```
|
||||
|
||||
When parameters change (parent re-renders):
|
||||
|
||||
```
|
||||
SetParametersAsync() [parameters updated]
|
||||
↓
|
||||
OnParametersSet() [NOT OnInitialized - that runs once only]
|
||||
↓
|
||||
ShouldRender()
|
||||
↓
|
||||
OnAfterRender()
|
||||
```
|
||||
|
||||
### Lifecycle Methods Detailed
|
||||
|
||||
#### SetParametersAsync()
|
||||
|
||||
- **When**: First method called, before initialization
|
||||
- **Purpose**: Set component parameters
|
||||
- **Usage**: Rarely overridden, use OnInitialized instead
|
||||
- **Code Example**:
|
||||
|
||||
```csharp
|
||||
public override async Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
// Custom parameter processing if needed
|
||||
await base.SetParametersAsync(parameters);
|
||||
}
|
||||
```
|
||||
|
||||
#### OnInitialized / OnInitializedAsync()
|
||||
|
||||
- **When**: Once per component lifetime, after parameters set
|
||||
- **Purpose**: Initialize component state, load data
|
||||
- **Runs**: Only ONCE, even if parameters change
|
||||
- **Code Example**:
|
||||
|
||||
```csharp
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
data = await Service.LoadDataAsync();
|
||||
}
|
||||
```
|
||||
|
||||
**Common Uses:**
|
||||
|
||||
- Load initial data from API
|
||||
- Set up subscriptions
|
||||
- Initialize state based on parameters
|
||||
|
||||
#### OnParametersSet / OnParametersSetAsync()
|
||||
|
||||
- **When**: After parameters set, runs EVERY time parameters change
|
||||
- **Purpose**: React to parameter changes
|
||||
- **Runs**: Every time parent re-renders with different values
|
||||
- **Code Example**:
|
||||
|
||||
```csharp
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await base.OnParametersSetAsync();
|
||||
if (UserId != previousUserId)
|
||||
{
|
||||
data = await Service.LoadUserDataAsync(UserId);
|
||||
previousUserId = UserId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Common Uses:**
|
||||
|
||||
- Update UI based on new parameter values
|
||||
- Fetch new data when ID parameter changes
|
||||
- React to cascading parameter changes
|
||||
|
||||
#### ShouldRender()
|
||||
|
||||
- **When**: Before DOM rendering, decision point
|
||||
- **Purpose**: Optimize rendering by skipping unnecessary renders
|
||||
- **Returns**: true (render) or false (skip)
|
||||
- **Code Example**:
|
||||
|
||||
```csharp
|
||||
protected override bool ShouldRender()
|
||||
{
|
||||
// Only render if specific field changed
|
||||
return hasChanged;
|
||||
}
|
||||
```
|
||||
|
||||
**Common Optimizations:**
|
||||
|
||||
- Skip render if data unchanged
|
||||
- Prevent re-render from external events
|
||||
- Implement custom change detection
|
||||
|
||||
#### OnAfterRender / OnAfterRenderAsync()
|
||||
|
||||
- **When**: After component rendered to DOM
|
||||
- **Purpose**: Work with DOM, initialize JS libraries, final setup
|
||||
- **Parameter**: `firstRender` - true only on first render
|
||||
- **Code Example**:
|
||||
|
||||
```csharp
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Initialize JS library only once
|
||||
await JS.InvokeVoidAsync("initializeChart", elementRef);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Critical Use Case:**
|
||||
|
||||
```csharp
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
// Load JS module
|
||||
module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./scripts/myScript.js");
|
||||
|
||||
// Initialize library
|
||||
await module.InvokeVoidAsync("setupChart", element);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Always use `firstRender` check for one-time initialization. This prevents re-initializing on every parameter change.
|
||||
|
||||
## Component Parameters
|
||||
|
||||
### Parameter Declaration
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
// Simple parameter
|
||||
[Parameter]
|
||||
public string Title { get; set; } = "Default";
|
||||
|
||||
// Required parameter (C# 11+)
|
||||
[Parameter, EditorRequired]
|
||||
public int UserId { get; set; }
|
||||
|
||||
// Child content
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
// Event callback
|
||||
[Parameter]
|
||||
public EventCallback<string> OnValueChanged { get; set; }
|
||||
|
||||
// Cascading parameter
|
||||
[CascadingParameter]
|
||||
public ThemeInfo? CurrentTheme { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Parameter Best Practices
|
||||
|
||||
**Use Clear Names:**
|
||||
|
||||
```csharp
|
||||
// ✅ Good - clear intent
|
||||
[Parameter]
|
||||
public bool IsVisible { get; set; }
|
||||
|
||||
// ❌ Poor - ambiguous
|
||||
[Parameter]
|
||||
public bool State { get; set; }
|
||||
```
|
||||
|
||||
**Use Nullable Types for Optional:**
|
||||
|
||||
```csharp
|
||||
// ✅ Good - nullable indicates optional
|
||||
[Parameter]
|
||||
public string? OptionalValue { get; set; }
|
||||
|
||||
// ✅ Good - default value
|
||||
[Parameter]
|
||||
public int MaxItems { get; set; } = 10;
|
||||
|
||||
// ❌ Poor - not clear if optional
|
||||
[Parameter]
|
||||
public string RequiredValue { get; set; }
|
||||
```
|
||||
|
||||
**Use [EditorRequired] for Required Parameters (C# 11+):**
|
||||
|
||||
```csharp
|
||||
// ✅ Best practice - compiler enforces, IDE warns
|
||||
[Parameter, EditorRequired]
|
||||
public string Title { get; set; } = default!;
|
||||
|
||||
// Fallback for older C#
|
||||
[Parameter]
|
||||
public string Title { get; set; } = default!;
|
||||
```
|
||||
|
||||
**Use EventCallback for Child-to-Parent Communication:**
|
||||
|
||||
```csharp
|
||||
// ✅ Correct - EventCallback for async safety
|
||||
[Parameter]
|
||||
public EventCallback<string> OnValueChanged { get; set; }
|
||||
|
||||
// ✅ With custom args
|
||||
[Parameter]
|
||||
public EventCallback<ValueChangeEventArgs> OnValueChanged { get; set; }
|
||||
|
||||
// ❌ Avoid - direct Action, not async-safe
|
||||
[Parameter]
|
||||
public Action<string>? OnValueChanged { get; set; }
|
||||
```
|
||||
|
||||
### Parameter Change Detection
|
||||
|
||||
To know when a parameter changed:
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
private int previousUserId;
|
||||
|
||||
[Parameter]
|
||||
public int UserId { get; set; }
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (UserId != previousUserId)
|
||||
{
|
||||
previousUserId = UserId;
|
||||
await LoadUserData();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use a comparison strategy:
|
||||
|
||||
```csharp
|
||||
private object? previousCriteria;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var currentCriteria = (SearchId, SearchTerm);
|
||||
|
||||
if (!Equals(previousCriteria, currentCriteria))
|
||||
{
|
||||
previousCriteria = currentCriteria;
|
||||
await PerformSearch();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cascading Values
|
||||
|
||||
Cascading values allow ancestor components to provide data to all descendants without explicit parameter passing.
|
||||
|
||||
### Providing Cascading Values
|
||||
|
||||
```csharp
|
||||
<!-- Parent component -->
|
||||
<CascadingValue Value="@currentUser">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private User currentUser = new();
|
||||
}
|
||||
```
|
||||
|
||||
### Receiving Cascading Values
|
||||
|
||||
```csharp
|
||||
<!-- Child component anywhere in hierarchy -->
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public User? CurrentUser { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (CurrentUser == null)
|
||||
{
|
||||
// Handle missing cascading value
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Cascading Values
|
||||
|
||||
```csharp
|
||||
<!-- Provider -->
|
||||
<CascadingValue Value="@theme">
|
||||
<CascadingValue Value="@currentUser">
|
||||
<CascadingValue Value="@permissions">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
|
||||
<!-- Consumer - multiple parameters -->
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public Theme? Theme { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public User? CurrentUser { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public Permissions? Permissions { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Named Cascading Values
|
||||
|
||||
For disambiguation when multiple values of same type:
|
||||
|
||||
```csharp
|
||||
<!-- Provider -->
|
||||
<CascadingValue Value="@themeLight" Name="Light">
|
||||
<CascadingValue Value="@themeDark" Name="Dark">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
|
||||
<!-- Consumer -->
|
||||
@code {
|
||||
[CascadingParameter(Name = "Light")]
|
||||
public Theme? LightTheme { get; set; }
|
||||
|
||||
[CascadingParameter(Name = "Dark")]
|
||||
public Theme? DarkTheme { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
## RenderFragment for Component Composition
|
||||
|
||||
RenderFragment enables flexible component composition.
|
||||
|
||||
### Basic RenderFragment
|
||||
|
||||
```csharp
|
||||
<!-- Parent component -->
|
||||
<div>
|
||||
<h2>Header</h2>
|
||||
@ChildContent
|
||||
<footer>Footer</footer>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
|
||||
<!-- Usage -->
|
||||
<Layout>
|
||||
<p>This is the main content</p>
|
||||
</Layout>
|
||||
```
|
||||
|
||||
### Typed RenderFragment with Context
|
||||
|
||||
```csharp
|
||||
<!-- ListComponent.razor -->
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
@ItemTemplate(item)
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public IEnumerable<Item> Items { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment<Item>? ItemTemplate { get; set; }
|
||||
}
|
||||
|
||||
<!-- Usage -->
|
||||
<ListComponent Items="@items">
|
||||
<ItemTemplate Context="item">
|
||||
<div>@item.Name - @item.Price</div>
|
||||
</ItemTemplate>
|
||||
</ListComponent>
|
||||
```
|
||||
|
||||
### Multiple Named Content Areas
|
||||
|
||||
```csharp
|
||||
<!-- Card component with multiple slots -->
|
||||
<div class="card">
|
||||
<div class="card-header">@Header</div>
|
||||
<div class="card-body">@Body</div>
|
||||
<div class="card-footer">@Footer</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? Header { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Body { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Footer { get; set; }
|
||||
}
|
||||
|
||||
<!-- Usage -->
|
||||
<Card>
|
||||
<Header>
|
||||
<h3>Card Title</h3>
|
||||
</Header>
|
||||
<Body>
|
||||
<p>Card content</p>
|
||||
</Body>
|
||||
<Footer>
|
||||
<button>Action</button>
|
||||
</Footer>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## Component Best Practices
|
||||
|
||||
### Single Responsibility
|
||||
|
||||
- Each component should have one clear purpose
|
||||
- Avoid god components that do too much
|
||||
- Example: `UserProfile` component should focus on displaying user info, not handle complex business logic
|
||||
|
||||
### Composition Over Inheritance
|
||||
|
||||
- Use cascading values for shared state, not deep hierarchies
|
||||
- Compose components rather than creating base classes
|
||||
- Example: Create theme provider component instead of theme-aware base class
|
||||
|
||||
### Keep Components Simple
|
||||
|
||||
- Minimize `@code` block logic
|
||||
- Move complex logic to services
|
||||
- Example: Validation logic → ValidationService, not in component
|
||||
|
||||
### Proper Disposal
|
||||
|
||||
- Implement `IDisposable` or `IAsyncDisposable`
|
||||
- Unsubscribe from events
|
||||
- Dispose timers and resources
|
||||
|
||||
```csharp
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
private IJSObjectReference? module;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./myScript.js");
|
||||
}
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
if (module is not null)
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [state-management-events.md](state-management-events.md) for event handling and state updates. See [performance-advanced.md](performance-advanced.md) for optimization techniques.
|
||||
@@ -0,0 +1,589 @@
|
||||
# Blazor Forms & Validation
|
||||
|
||||
## EditForm Component
|
||||
|
||||
EditForm provides a complete form handling solution with data binding and validation.
|
||||
|
||||
### Basic EditForm
|
||||
|
||||
```csharp
|
||||
@page "/register"
|
||||
|
||||
<EditForm Model="@model" OnValidSubmit="@HandleValidSubmit">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary />
|
||||
|
||||
<div class="form-group">
|
||||
<label>Name:</label>
|
||||
<InputText @bind-Value="model.Name" class="form-control" />
|
||||
<ValidationMessage For="@(() => model.Name)" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Email:</label>
|
||||
<InputText @bind-Value="model.Email" class="form-control" />
|
||||
<ValidationMessage For="@(() => model.Email)" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">Register</button>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private RegistrationModel model = new();
|
||||
|
||||
private async Task HandleValidSubmit()
|
||||
{
|
||||
// Form is valid, process data
|
||||
await Service.RegisterUserAsync(model);
|
||||
}
|
||||
}
|
||||
|
||||
public class RegistrationModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100, MinimumLength = 2)]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
```
|
||||
|
||||
### EditForm Events
|
||||
|
||||
```csharp
|
||||
<EditForm Model="@model"
|
||||
OnValidSubmit="@OnValidSubmit"
|
||||
OnInvalidSubmit="@OnInvalidSubmit"
|
||||
OnSubmit="@OnSubmit">
|
||||
<!-- Form content -->
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private async Task OnValidSubmit()
|
||||
{
|
||||
// Fires when form is valid and submitted
|
||||
}
|
||||
|
||||
private async Task OnInvalidSubmit()
|
||||
{
|
||||
// Fires when form is invalid and submitted
|
||||
}
|
||||
|
||||
private async Task OnSubmit()
|
||||
{
|
||||
// Fires for any submit (valid or invalid)
|
||||
// Useful for custom validation logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Form State Control
|
||||
|
||||
```csharp
|
||||
@inject EditFormService FormService
|
||||
|
||||
<EditForm Model="@model" @ref="form">
|
||||
<!-- Form content -->
|
||||
</EditForm>
|
||||
|
||||
<button @onclick="Submit">Submit</button>
|
||||
<button @onclick="Reset">Reset</button>
|
||||
<button @onclick="CheckValid">Is Valid?</button>
|
||||
|
||||
@code {
|
||||
private EditForm? form;
|
||||
private UserModel model = new();
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
// Manually trigger validation and submission
|
||||
await form!.RequestValidationAsync();
|
||||
|
||||
// Check if valid
|
||||
if (form!.EditContext.IsModified() && form!.EditContext.Validate())
|
||||
{
|
||||
// Process form
|
||||
}
|
||||
}
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
// Reset all fields to default
|
||||
form!.EditContext.ResetEditingItemAsync();
|
||||
}
|
||||
|
||||
private void CheckValid()
|
||||
{
|
||||
bool isValid = form!.EditContext.Validate();
|
||||
Console.WriteLine($"Form valid: {isValid}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Input Components
|
||||
|
||||
### Text Input
|
||||
|
||||
```csharp
|
||||
<InputText @bind-Value="model.Name" class="form-control" />
|
||||
<InputTextArea @bind-Value="model.Description" rows="4" />
|
||||
|
||||
@code {
|
||||
private UserModel model = new();
|
||||
}
|
||||
```
|
||||
|
||||
### Numeric Input
|
||||
|
||||
```csharp
|
||||
<InputNumber @bind-Value="model.Age" class="form-control" />
|
||||
<InputNumber @bind-Value="model.Price" @bind-Value:format="N2" />
|
||||
|
||||
@code {
|
||||
private int age;
|
||||
private decimal price;
|
||||
}
|
||||
```
|
||||
|
||||
**Format specifiers:**
|
||||
|
||||
- `N2` - Number with 2 decimal places
|
||||
- `C` - Currency
|
||||
- `P` - Percentage
|
||||
- `D` - Date
|
||||
- `X` - Hexadecimal
|
||||
|
||||
### Date Input
|
||||
|
||||
```csharp
|
||||
<InputDate @bind-Value="model.BirthDate" />
|
||||
<InputDate @bind-Value="model.StartTime" Type="InputDateType.DateTimeLocal" />
|
||||
|
||||
@code {
|
||||
private DateTime birthDate;
|
||||
private DateTime startTime;
|
||||
}
|
||||
```
|
||||
|
||||
**Types:**
|
||||
|
||||
- `InputDateType.Date` - Date only (default)
|
||||
- `InputDateType.DateTimeLocal` - Date and time
|
||||
- `InputDateType.Month` - Month and year
|
||||
- `InputDateType.Time` - Time only
|
||||
|
||||
### Select/Dropdown
|
||||
|
||||
```csharp
|
||||
<InputSelect @bind-Value="model.Category" class="form-control">
|
||||
<option value="">Select a category...</option>
|
||||
<option value="electronics">Electronics</option>
|
||||
<option value="clothing">Clothing</option>
|
||||
</InputSelect>
|
||||
|
||||
<!-- Dynamic options from data -->
|
||||
<InputSelect @bind-Value="model.CategoryId">
|
||||
<option value="">Select...</option>
|
||||
@foreach (var cat in categories)
|
||||
{
|
||||
<option value="@cat.Id">@cat.Name</option>
|
||||
}
|
||||
</InputSelect>
|
||||
|
||||
@code {
|
||||
private string selectedCategory = "";
|
||||
private List<Category> categories = [];
|
||||
}
|
||||
```
|
||||
|
||||
### Checkbox
|
||||
|
||||
```csharp
|
||||
<InputCheckbox @bind-Value="model.AgreeToTerms" />
|
||||
Accept terms of service?
|
||||
|
||||
@code {
|
||||
private bool agreeToTerms = false;
|
||||
}
|
||||
```
|
||||
|
||||
### Radio Buttons
|
||||
|
||||
```csharp
|
||||
<InputRadioGroup @bind-Value="model.Preference">
|
||||
<div>
|
||||
<InputRadio Value="@("option1")" />
|
||||
<label>Option 1</label>
|
||||
</div>
|
||||
<div>
|
||||
<InputRadio Value="@("option2")" />
|
||||
<label>Option 2</label>
|
||||
</div>
|
||||
</InputRadioGroup>
|
||||
|
||||
@code {
|
||||
private string preference = "option1";
|
||||
}
|
||||
```
|
||||
|
||||
### File Upload
|
||||
|
||||
```csharp
|
||||
<InputFile OnChange="@HandleFileSelect" />
|
||||
|
||||
@code {
|
||||
private async Task HandleFileSelect(InputFileChangeEventArgs e)
|
||||
{
|
||||
var file = e.File;
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var buffer = new byte[stream.Length];
|
||||
await stream.ReadAsync(buffer);
|
||||
|
||||
// Process file
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
### DataAnnotations Validation
|
||||
|
||||
```csharp
|
||||
public class UserModel
|
||||
{
|
||||
[Required(ErrorMessage = "Name is required")]
|
||||
[StringLength(100, MinimumLength = 2)]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[EmailAddress(ErrorMessage = "Invalid email format")]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Range(18, 120, ErrorMessage = "Age must be 18-120")]
|
||||
public int Age { get; set; }
|
||||
|
||||
[Url]
|
||||
public string? Website { get; set; }
|
||||
|
||||
[Phone]
|
||||
public string? PhoneNumber { get; set; }
|
||||
|
||||
[CreditCard]
|
||||
public string? CardNumber { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Common Validators:**
|
||||
|
||||
- `[Required]` - Field must have value
|
||||
- `[StringLength(max)]` - Max length
|
||||
- `[StringLength(max, MinimumLength = min)]` - Min and max
|
||||
- `[EmailAddress]` - Valid email format
|
||||
- `[Range(min, max)]` - Numeric range
|
||||
- `[Url]` - Valid URL format
|
||||
- `[Phone]` - Valid phone format
|
||||
- `[CreditCard]` - Valid credit card format
|
||||
- `[RegularExpression(pattern)]` - Regex match
|
||||
|
||||
### ValidationSummary
|
||||
|
||||
Shows all validation errors for the form:
|
||||
|
||||
```csharp
|
||||
<EditForm Model="@model" OnValidSubmit="@HandleSubmit">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary />
|
||||
|
||||
<InputText @bind-Value="model.Name" />
|
||||
<InputText @bind-Value="model.Email" />
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</EditForm>
|
||||
```
|
||||
|
||||
Displays as:
|
||||
|
||||
```
|
||||
- Name is required
|
||||
- Email is required
|
||||
```
|
||||
|
||||
### ValidationMessage
|
||||
|
||||
Shows validation error for specific field:
|
||||
|
||||
```csharp
|
||||
<InputText @bind-Value="model.Name" />
|
||||
<ValidationMessage For="@(() => model.Name)" />
|
||||
|
||||
<!-- Custom CSS class -->
|
||||
<ValidationMessage For="@(() => model.Email)" class="text-danger" />
|
||||
```
|
||||
|
||||
### Custom Validation
|
||||
|
||||
Implement `IValidatableObject` for complex validation rules:
|
||||
|
||||
```csharp
|
||||
public class UserModel : IValidatableObject
|
||||
{
|
||||
public string Email { get; set; } = "";
|
||||
public string ConfirmEmail { get; set; } = "";
|
||||
|
||||
[Range(18, 100)]
|
||||
public int Age { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
// Compare email fields
|
||||
if (Email != ConfirmEmail)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Email addresses must match",
|
||||
new[] { nameof(ConfirmEmail) }
|
||||
);
|
||||
}
|
||||
|
||||
// Custom age validation
|
||||
if (Age > 0 && Age < 18 && HasRestrictedContent)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Users under 18 cannot access this content",
|
||||
new[] { nameof(Age) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasRestrictedContent { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Validators
|
||||
|
||||
Create reusable custom validators:
|
||||
|
||||
```csharp
|
||||
public class MinimumAgeAttribute : ValidationAttribute
|
||||
{
|
||||
private readonly int _minimumAge;
|
||||
|
||||
public MinimumAgeAttribute(int minimumAge)
|
||||
{
|
||||
_minimumAge = minimumAge;
|
||||
}
|
||||
|
||||
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
|
||||
{
|
||||
if (value is DateTime birthDate)
|
||||
{
|
||||
var age = DateTime.Today.Year - birthDate.Year;
|
||||
if (birthDate.Date > DateTime.Today.AddYears(-age)) age--;
|
||||
|
||||
if (age < _minimumAge)
|
||||
{
|
||||
return new ValidationResult($"Minimum age is {_minimumAge}");
|
||||
}
|
||||
}
|
||||
|
||||
return ValidationResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
public class UserModel
|
||||
{
|
||||
[MinimumAge(18)]
|
||||
public DateTime BirthDate { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Async Validation
|
||||
|
||||
```csharp
|
||||
public class UniqueEmailAttribute : ValidationAttribute
|
||||
{
|
||||
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
|
||||
{
|
||||
// Can't use async in ValidationAttribute
|
||||
// Use EditContext instead (see below)
|
||||
return ValidationResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
// Better approach: Manual validation in component
|
||||
@code {
|
||||
private async Task HandleValidSubmit()
|
||||
{
|
||||
// Check email availability before submit
|
||||
bool isUnique = await Service.IsEmailUniqueAsync(model.Email);
|
||||
if (!isUnique)
|
||||
{
|
||||
form!.EditContext.AddValidationMessages(
|
||||
FieldIdentifier.Create(() => model.Email),
|
||||
new[] { "Email is already registered" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await SaveUserAsync(model);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Form Patterns
|
||||
|
||||
### Loading State
|
||||
|
||||
```csharp
|
||||
@if (isSubmitting)
|
||||
{
|
||||
<p>Saving...</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="@model" OnValidSubmit="@SubmitAsync">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary />
|
||||
|
||||
<InputText @bind-Value="model.Name" />
|
||||
<button type="submit" disabled="@isSubmitting">
|
||||
@(isSubmitting ? "Saving..." : "Submit")
|
||||
</button>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool isSubmitting;
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
isSubmitting = true;
|
||||
try
|
||||
{
|
||||
await Service.SaveAsync(model);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```csharp
|
||||
<EditForm Model="@model" OnValidSubmit="@SubmitAsync">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
|
||||
<ValidationSummary />
|
||||
|
||||
<InputText @bind-Value="model.Name" />
|
||||
<button type="submit">Submit</button>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private string? errorMessage;
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
errorMessage = null;
|
||||
await Service.SaveAsync(model);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Step Form
|
||||
|
||||
```csharp
|
||||
@page "/wizard"
|
||||
|
||||
@if (currentStep == 1)
|
||||
{
|
||||
<h2>Step 1: Basic Info</h2>
|
||||
<InputText @bind-Value="model.Name" />
|
||||
<button @onclick="NextStep">Next</button>
|
||||
}
|
||||
else if (currentStep == 2)
|
||||
{
|
||||
<h2>Step 2: Contact Info</h2>
|
||||
<InputText @bind-Value="model.Email" />
|
||||
<button @onclick="PreviousStep">Back</button>
|
||||
<button @onclick="NextStep">Next</button>
|
||||
}
|
||||
else if (currentStep == 3)
|
||||
{
|
||||
<h2>Step 3: Confirm</h2>
|
||||
<p>Name: @model.Name</p>
|
||||
<p>Email: @model.Email</p>
|
||||
<button @onclick="PreviousStep">Back</button>
|
||||
<button @onclick="SubmitAsync">Submit</button>
|
||||
}
|
||||
|
||||
@code {
|
||||
private int currentStep = 1;
|
||||
private UserModel model = new();
|
||||
|
||||
private void NextStep() => currentStep++;
|
||||
private void PreviousStep() => currentStep--;
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
await Service.RegisterAsync(model);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Real-Time Field Validation
|
||||
|
||||
```csharp
|
||||
<input @bind="email" @bind:event="oninput" @onblur="ValidateEmail" />
|
||||
@if (!string.IsNullOrEmpty(emailError))
|
||||
{
|
||||
<span class="error">@emailError</span>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string email = "";
|
||||
private string? emailError;
|
||||
|
||||
private void ValidateEmail()
|
||||
{
|
||||
if (string.IsNullOrEmpty(email))
|
||||
{
|
||||
emailError = "Email is required";
|
||||
}
|
||||
else if (!email.Contains("@"))
|
||||
{
|
||||
emailError = "Invalid email format";
|
||||
}
|
||||
else
|
||||
{
|
||||
emailError = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [state-management-events.md](state-management-events.md) for data binding patterns. See [authentication-authorization.md](authentication-authorization.md) for role-based form customization.
|
||||
@@ -0,0 +1,561 @@
|
||||
# Blazor Performance & Advanced Patterns
|
||||
|
||||
## Rendering Optimization
|
||||
|
||||
### ShouldRender Override
|
||||
|
||||
Control when components re-render to prevent unnecessary rendering cycles.
|
||||
|
||||
```csharp
|
||||
@page "/optimized"
|
||||
|
||||
<button @onclick="IncrementCount">Clicked @count times</button>
|
||||
<ChildComponent Value="@value" />
|
||||
|
||||
@code {
|
||||
private int count = 0;
|
||||
private string value = "test";
|
||||
|
||||
protected override bool ShouldRender()
|
||||
{
|
||||
// Only render if value changed, not if count changed
|
||||
// This component doesn't display count directly
|
||||
return false; // Skip render
|
||||
}
|
||||
|
||||
private void IncrementCount()
|
||||
{
|
||||
count++;
|
||||
// Component won't re-render, child component won't re-render either
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tracking Changed Fields
|
||||
|
||||
```csharp
|
||||
@page "/tracker"
|
||||
|
||||
<button @onclick="UpdateName">Update Name</button>
|
||||
<button @onclick="UpdateAge">Update Age</button>
|
||||
|
||||
<p>Name: @name</p>
|
||||
<p>Age: @age</p>
|
||||
|
||||
@code {
|
||||
private string? name;
|
||||
private int age;
|
||||
private bool nameChanged = false;
|
||||
private bool ageChanged = false;
|
||||
|
||||
protected override bool ShouldRender()
|
||||
{
|
||||
if (!nameChanged && !ageChanged)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
nameChanged = false;
|
||||
ageChanged = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void UpdateName()
|
||||
{
|
||||
name = "New Name";
|
||||
nameChanged = true;
|
||||
}
|
||||
|
||||
private void UpdateAge()
|
||||
{
|
||||
age = 30;
|
||||
ageChanged = true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Directive for List Items
|
||||
|
||||
```csharp
|
||||
@page "/list"
|
||||
|
||||
<button @onclick="AddItem">Add Item</button>
|
||||
|
||||
@foreach (var item in items)
|
||||
{
|
||||
<!-- WITHOUT @key - new ItemComponent created for each item -->
|
||||
<!-- <ItemComponent Item="@item" />-->
|
||||
|
||||
<!-- WITH @key - same ItemComponent reused if item.Id stays in list -->
|
||||
<ItemComponent @key="item.Id" Item="@item" />
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Item> items = [];
|
||||
|
||||
private void AddItem()
|
||||
{
|
||||
items = items.Prepend(new Item { Id = Guid.NewGuid(), Name = "New" }).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public class Item
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Why @key matters:**
|
||||
|
||||
- Helps Blazor's diffing algorithm match old components to new items
|
||||
- Prevents component state loss during list reordering
|
||||
- Improves performance with large lists
|
||||
|
||||
### IDisposable for Cleanup
|
||||
|
||||
```csharp
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
private IJSObjectReference? module;
|
||||
private Timer? timer;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
timer = new Timer(_ => UpdateAsync(), null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./myScript.js");
|
||||
}
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
timer?.Dispose();
|
||||
|
||||
if (module is not null)
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Virtualization
|
||||
|
||||
Virtualize large lists to render only visible items.
|
||||
|
||||
### Basic Virtualization
|
||||
|
||||
```csharp
|
||||
@page "/large-list"
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
|
||||
<Virtualize Items="@largeList" Context="item">
|
||||
<div class="item">
|
||||
<p>@item.Id - @item.Name</p>
|
||||
</div>
|
||||
</Virtualize>
|
||||
|
||||
@code {
|
||||
private List<Item> largeList = [];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Generate 100,000 items
|
||||
largeList = Enumerable.Range(1, 100000)
|
||||
.Select(i => new Item { Id = i, Name = $"Item {i}" })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Virtualization (Infinite Scroll)
|
||||
|
||||
```csharp
|
||||
@page "/infinite-scroll"
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
|
||||
<Virtualize ItemsProvider="@LoadItems" Context="item" OverscanCount="5">
|
||||
<div>@item.Name</div>
|
||||
</Virtualize>
|
||||
|
||||
@code {
|
||||
private async ValueTask<ItemsProviderResult<Item>> LoadItems(
|
||||
ItemsProviderRequest request)
|
||||
{
|
||||
// Simulate loading from server
|
||||
var startIndex = request.StartIndex;
|
||||
var count = request.Count;
|
||||
|
||||
var items = await Service.GetItemsAsync(startIndex, count);
|
||||
|
||||
// Return items and total count for scrollbar sizing
|
||||
return new ItemsProviderResult<Item>(items, totalItemCount: 1000000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `Items` - Static list of items to virtualize
|
||||
- `ItemsProvider` - Async method to load items on demand
|
||||
- `OverscanCount` - Extra items to render outside viewport (default 3)
|
||||
- `ItemSize` - Estimated height for scrollbar calculation
|
||||
|
||||
## JavaScript Interop
|
||||
|
||||
### Invoke JavaScript from C #
|
||||
|
||||
```csharp
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<button @onclick="CallJavaScript">Click me</button>
|
||||
|
||||
@code {
|
||||
private async Task CallJavaScript()
|
||||
{
|
||||
// Simple call - no return value
|
||||
await JS.InvokeVoidAsync("console.log", "Hello from Blazor");
|
||||
|
||||
// With return value
|
||||
var result = await JS.InvokeAsync<string>("myFunction", arg1, arg2);
|
||||
|
||||
// Generic call with any return type
|
||||
var data = await JS.InvokeAsync<Data>("loadData");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JS Module Isolation (Recommended)
|
||||
|
||||
```csharp
|
||||
// Component.razor
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div @ref="element">
|
||||
<canvas id="chart"></canvas>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ElementReference element;
|
||||
private IJSObjectReference? module;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Import JS module
|
||||
module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./scripts/chart.js");
|
||||
|
||||
// Call exported function
|
||||
await module.InvokeVoidAsync("initChart", element);
|
||||
}
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
if (module is not null)
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* scripts/chart.js */
|
||||
export function initChart(element) {
|
||||
const canvas = element.querySelector('#chart');
|
||||
// Initialize chart library
|
||||
}
|
||||
```
|
||||
|
||||
### Invoke C# from JavaScript
|
||||
|
||||
```csharp
|
||||
// Component.razor
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<button @onclick="SetupInterop">Setup</button>
|
||||
|
||||
@code {
|
||||
private IJSObjectReference? module;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./scripts/interop.js");
|
||||
|
||||
// Pass C# object reference to JS
|
||||
var objRef = DotNetObjectReference.Create(this);
|
||||
await module.InvokeVoidAsync("setupInterop", objRef);
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task HandleJSEvent(string data)
|
||||
{
|
||||
Console.WriteLine($"JS called C#: {data}");
|
||||
// Update component state
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
if (module is not null)
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* scripts/interop.js */
|
||||
let dotnetHelper;
|
||||
|
||||
export function setupInterop(dotnetRef) {
|
||||
dotnetHelper = dotnetRef;
|
||||
|
||||
// Call C# method from JS
|
||||
document.addEventListener('click', async (e) => {
|
||||
await dotnetHelper.invokeMethodAsync('HandleJSEvent', 'User clicked');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling in Interop
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
private async Task SafeInvokeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("riskyFunction");
|
||||
}
|
||||
catch (JSException jsEx)
|
||||
{
|
||||
Console.WriteLine($"JS error: {jsEx.Message}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine("JS call was cancelled");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Lazy Loading
|
||||
|
||||
Load assemblies and components on demand.
|
||||
|
||||
### Lazy-Loaded Component Routes
|
||||
|
||||
```csharp
|
||||
<!-- App.razor -->
|
||||
<Router AppAssembly="@typeof(App).Assembly"
|
||||
AdditionalAssemblies="@additionalAssemblies"
|
||||
OnNavigateAsync="@OnNavigateAsync">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<p>Loading...</p>
|
||||
</NotFound>
|
||||
</Router>
|
||||
|
||||
@code {
|
||||
private List<Assembly>? additionalAssemblies;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
additionalAssemblies = new();
|
||||
}
|
||||
|
||||
private async Task OnNavigateAsync(NavigationContext context)
|
||||
{
|
||||
// Load admin assembly only when accessing /admin
|
||||
if (context.Path.StartsWith("admin"))
|
||||
{
|
||||
var adminAssembly = await JS.InvokeAsync<byte[]>(
|
||||
"fetch", "./_framework/admin.wasm");
|
||||
|
||||
additionalAssemblies!.Add(Assembly.Load(adminAssembly));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## WASM Performance Best Practices
|
||||
|
||||
### AOT Compilation
|
||||
|
||||
```xml
|
||||
<!-- .csproj -->
|
||||
<PropertyGroup>
|
||||
<RunAOTCompilation>true</RunAOTCompilation>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
Benefits:
|
||||
|
||||
- No JIT compilation at runtime
|
||||
- Faster startup time
|
||||
- ~20% larger download
|
||||
- Production recommended
|
||||
|
||||
### Trimming
|
||||
|
||||
```xml
|
||||
<!-- .csproj -->
|
||||
<PropertyGroup>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
Benefits:
|
||||
|
||||
- Removes unused code
|
||||
- ~40% smaller download
|
||||
- May cause runtime errors if reflection-based code removed
|
||||
- Test thoroughly in Release build
|
||||
|
||||
### Compression
|
||||
|
||||
```xml
|
||||
<!-- .csproj -->
|
||||
<PropertyGroup>
|
||||
<BlazorWebAssemblyEnableCompression>true</BlazorWebAssemblyEnableCompression>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
Server-side (in Program.cs):
|
||||
|
||||
```csharp
|
||||
app.UseResponseCompression();
|
||||
|
||||
builder.Services.AddResponseCompression(opts =>
|
||||
{
|
||||
opts.Filters.Add(new GzipCompressionProvider());
|
||||
opts.Filters.Add(new BrotliCompressionProvider());
|
||||
});
|
||||
```
|
||||
|
||||
### Minimize JavaScript Interop
|
||||
|
||||
```csharp
|
||||
// INEFFICIENT - Many JS calls
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
await JS.InvokeVoidAsync("updateUI", i);
|
||||
}
|
||||
|
||||
// EFFICIENT - Single JS call with batch data
|
||||
var updates = Enumerable.Range(0, 1000).ToList();
|
||||
await JS.InvokeVoidAsync("updateUIBatch", updates);
|
||||
```
|
||||
|
||||
## Error Boundaries
|
||||
|
||||
Handle component errors gracefully.
|
||||
|
||||
```csharp
|
||||
@page "/error-demo"
|
||||
|
||||
<ErrorBoundary>
|
||||
<ChildContent>
|
||||
<ChildComponent />
|
||||
</ChildContent>
|
||||
<ErrorContent Context="ex">
|
||||
<div class="alert alert-danger">
|
||||
<h4>Error</h4>
|
||||
<p>@ex.Message</p>
|
||||
<button @onclick="ResetError">Try Again</button>
|
||||
</div>
|
||||
</ErrorContent>
|
||||
</ErrorBoundary>
|
||||
|
||||
@code {
|
||||
private ErrorBoundary? errorBoundary;
|
||||
|
||||
private async Task ResetError()
|
||||
{
|
||||
await errorBoundary!.RecoverAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CSS Isolation
|
||||
|
||||
Scope CSS to specific components.
|
||||
|
||||
```html
|
||||
<!-- MyComponent.razor -->
|
||||
<div class="container">
|
||||
<h1>@Title</h1>
|
||||
</div>
|
||||
|
||||
<!-- MyComponent.razor.css -->
|
||||
.container {
|
||||
background-color: blue;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No global namespace pollution
|
||||
- Component-specific styling
|
||||
- CSS automatically scoped to component
|
||||
- Compiled into assembly
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
### Performance
|
||||
|
||||
- Use `@key` on list items
|
||||
- Override `ShouldRender()` to prevent unnecessary renders
|
||||
- Use virtualization for large lists
|
||||
- Minimize JavaScript interop calls
|
||||
- Enable AOT compilation and trimming for WASM
|
||||
|
||||
### JavaScript Interop
|
||||
|
||||
- Use module isolation pattern
|
||||
- Always dispose JS module references
|
||||
- Handle JS exceptions properly
|
||||
- Only call JS in `OnAfterRender` with firstRender check
|
||||
- Minimize interop calls for performance
|
||||
|
||||
### Architecture
|
||||
|
||||
- Keep components simple and focused
|
||||
- Move logic to services
|
||||
- Use cascading values for shared state
|
||||
- Implement IDisposable for cleanup
|
||||
- Validate authorization on server side
|
||||
|
||||
### User Experience
|
||||
|
||||
- Show loading states during async operations
|
||||
- Provide error feedback
|
||||
- Use AuthorizeView for conditional rendering
|
||||
- Implement error boundaries
|
||||
- Test on slow connections
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [components-lifecycle.md](components-lifecycle.md) for component disposal patterns. See [state-management-events.md](state-management-events.md) for state update optimization.
|
||||
@@ -0,0 +1,492 @@
|
||||
# Blazor Routing & Navigation
|
||||
|
||||
## Route Definition
|
||||
|
||||
Routes map URL paths to Blazor components.
|
||||
|
||||
### Basic Route Definition
|
||||
|
||||
```csharp
|
||||
@page "/product"
|
||||
@page "/product/{id}"
|
||||
|
||||
<h3>Product: @Id</h3>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? Id { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- `@page` directive makes component routable
|
||||
- Parameter name in URL (`{id}`) must match parameter name in `@code` block
|
||||
- Multiple `@page` directives supported (same component, multiple routes)
|
||||
|
||||
### Route Parameters
|
||||
|
||||
```csharp
|
||||
@page "/product/{id}"
|
||||
<p>Product: @id</p>
|
||||
|
||||
@page "/category/{categoryId}/product/{productId}"
|
||||
<p>Category: @categoryId, Product: @productId</p>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? id { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? categoryId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? productId { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Parameter Matching:**
|
||||
- Blazor matches route segments to parameter names (case-insensitive)
|
||||
- `{id}` in route matches `Id` parameter
|
||||
- Extra parameters in URL are ignored
|
||||
|
||||
### Route Constraints
|
||||
|
||||
Route constraints enforce parameter type and format:
|
||||
|
||||
```csharp
|
||||
@page "/product/{id:int}" <!-- Integer only -->
|
||||
@page "/order/{orderId:long}" <!-- Long integer -->
|
||||
@page "/user/{id:guid}" <!-- GUID format -->
|
||||
@page "/article/{slug:string}" <!-- String (default) -->
|
||||
@page "/event/{date:datetime}" <!-- DateTime format -->
|
||||
@page "/price/{amount:decimal}" <!-- Decimal number -->
|
||||
@page "/flag/{active:bool}" <!-- Boolean -->
|
||||
@page "/value/{num:double}" <!-- Double/Float -->
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int id { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Guid id { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool active { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Built-in Constraints:**
|
||||
- `:int` - Integer values
|
||||
- `:long` - Long integers
|
||||
- `:guid` - GUID format
|
||||
- `:bool` - Boolean
|
||||
- `:datetime` - DateTime format
|
||||
- `:decimal` - Decimal numbers
|
||||
- `:double` / `:float` - Floating point
|
||||
- `:string` - Any string (default)
|
||||
|
||||
### Optional Route Parameters
|
||||
|
||||
```csharp
|
||||
@page "/search"
|
||||
@page "/search/{searchTerm}"
|
||||
|
||||
<p>Search term: @(searchTerm ?? "All results")</p>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? searchTerm { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Catch-All Routes
|
||||
|
||||
```csharp
|
||||
@page "/{*pageRoute}"
|
||||
|
||||
<p>Page not found: @pageRoute</p>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? pageRoute { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
```csharp
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<button @onclick="GoHome">Go Home</button>
|
||||
<button @onclick="GoToUser">Go to User</button>
|
||||
|
||||
@code {
|
||||
private void GoHome()
|
||||
{
|
||||
Navigation.NavigateTo("/");
|
||||
}
|
||||
|
||||
private void GoToUser()
|
||||
{
|
||||
Navigation.NavigateTo("/user/123");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation with Options
|
||||
|
||||
```csharp
|
||||
// Replace browser history entry instead of adding new one
|
||||
Navigation.NavigateTo("/home", replace: true);
|
||||
|
||||
// Force full page reload from server
|
||||
Navigation.NavigateTo("/refresh", forceLoad: true);
|
||||
|
||||
// Combine options
|
||||
Navigation.NavigateTo("/new-page", replace: true, forceLoad: true);
|
||||
```
|
||||
|
||||
**When to use `forceLoad: true`:**
|
||||
- After logout to clear client-side state
|
||||
- Accessing completely different app
|
||||
- Clearing service worker cache
|
||||
- Full server-side initialization needed
|
||||
|
||||
### NavLink Component
|
||||
|
||||
NavLink automatically highlights active route:
|
||||
|
||||
```csharp
|
||||
<NavLink href="/home" Match="NavLinkMatch.All">
|
||||
<span class="icon">🏠</span> Home
|
||||
</NavLink>
|
||||
|
||||
<NavLink href="/products" Match="NavLinkMatch.Prefix">
|
||||
<span class="icon">📦</span> Products
|
||||
</NavLink>
|
||||
|
||||
<NavLink href="/about" Match="NavLinkMatch.None">
|
||||
About
|
||||
</NavLink>
|
||||
|
||||
@code {
|
||||
// CSS class applied to active NavLink: active
|
||||
}
|
||||
```
|
||||
|
||||
**Match options:**
|
||||
- `NavLinkMatch.All` - Exact URL match required
|
||||
- `NavLinkMatch.Prefix` - URL starts with href (default)
|
||||
- `NavLinkMatch.None` - Never highlights
|
||||
|
||||
**CSS:**
|
||||
```css
|
||||
a.active {
|
||||
color: white;
|
||||
background-color: blue;
|
||||
}
|
||||
```
|
||||
|
||||
### Listen to Location Changes
|
||||
|
||||
```csharp
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<p>Current location: @Navigation.Uri</p>
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Navigation.LocationChanged += LocationChanged;
|
||||
}
|
||||
|
||||
private void LocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"New location: {e.Location}");
|
||||
|
||||
// React to navigation
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Navigation.LocationChanged -= LocationChanged;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Query Strings
|
||||
|
||||
### Reading Query Parameters
|
||||
|
||||
```csharp
|
||||
@page "/search"
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<p>Search results for: @searchQuery</p>
|
||||
|
||||
@code {
|
||||
private string? searchQuery;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
searchQuery = query["q"];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:** `/search?q=blazor` → `searchQuery = "blazor"`
|
||||
|
||||
### Building Query Strings
|
||||
|
||||
```csharp
|
||||
private void Search(string term)
|
||||
{
|
||||
Navigation.NavigateTo($"/search?q={Uri.EscapeDataString(term)}");
|
||||
}
|
||||
|
||||
// Or use QueryHelpers (in .NET 6+)
|
||||
var query = new Dictionary<string, string>
|
||||
{
|
||||
{ "q", "blazor" },
|
||||
{ "page", "1" }
|
||||
};
|
||||
|
||||
var url = NavigationManager.GetUriWithQueryParameters("/search", query);
|
||||
Navigation.NavigateTo(url);
|
||||
```
|
||||
|
||||
### Multiple Query Parameters
|
||||
|
||||
```csharp
|
||||
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
|
||||
var category = query["category"];
|
||||
var page = int.TryParse(query["page"], out var p) ? p : 1;
|
||||
var sort = query["sort"] ?? "name";
|
||||
```
|
||||
|
||||
**Usage:** `/products?category=electronics&page=2&sort=price`
|
||||
|
||||
## Router Configuration
|
||||
|
||||
The Router component in `App.razor` configures routing:
|
||||
|
||||
```csharp
|
||||
<!-- App.razor -->
|
||||
<Router AppAssembly="@typeof(App).Assembly"
|
||||
AdditionalAssemblies="@additionalAssemblies"
|
||||
OnNavigateAsync="@OnNavigateAsync">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
||||
<PageTitle>@pageTitle</PageTitle>
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(MainLayout)">
|
||||
<p>Page not found</p>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
|
||||
@code {
|
||||
private List<Assembly>? additionalAssemblies;
|
||||
private string pageTitle = "Loading...";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Load assemblies dynamically if needed
|
||||
additionalAssemblies = new List<Assembly>
|
||||
{
|
||||
typeof(SomeOtherAssembly).Assembly
|
||||
};
|
||||
}
|
||||
|
||||
private async Task OnNavigateAsync(NavigationContext context)
|
||||
{
|
||||
// Can be used for lazy loading assemblies
|
||||
// Not commonly needed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Layouts
|
||||
|
||||
Layouts are parent components that wrap pages.
|
||||
|
||||
### Define a Layout
|
||||
|
||||
```csharp
|
||||
<!-- Layouts/MainLayout.razor -->
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<header>@Header</header>
|
||||
<nav>@Navigation</nav>
|
||||
|
||||
<main>@Body</main>
|
||||
|
||||
<footer>@Footer</footer>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? Header { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Navigation { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Body { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Footer { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Apply Layout to Page
|
||||
|
||||
```csharp
|
||||
@page "/products"
|
||||
@layout MainLayout
|
||||
|
||||
<h2>Products</h2>
|
||||
```
|
||||
|
||||
### Apply Layout to Multiple Pages
|
||||
|
||||
```csharp
|
||||
<!-- _Imports.razor -->
|
||||
@layout MainLayout
|
||||
```
|
||||
|
||||
Add this line to `_Imports.razor` to apply layout to all components in folder and below.
|
||||
|
||||
### Nested Layouts
|
||||
|
||||
```csharp
|
||||
<!-- AdminLayout inherits from MainLayout -->
|
||||
@inherits MainLayout
|
||||
|
||||
<aside>Admin sidebar</aside>
|
||||
@Body
|
||||
```
|
||||
|
||||
## Page Titles
|
||||
|
||||
Update page title (browser tab) dynamically:
|
||||
|
||||
```csharp
|
||||
@page "/products/{id}"
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>@title</PageTitle>
|
||||
|
||||
<h1>@title</h1>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? id { get; set; }
|
||||
|
||||
private string? title;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
title = await LoadProductTitleAsync(id);
|
||||
}
|
||||
|
||||
private async Task<string> LoadProductTitleAsync(string? id)
|
||||
{
|
||||
// Load from service
|
||||
return $"Product {id}";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Routing Patterns
|
||||
|
||||
### Master-Detail Pattern
|
||||
|
||||
```csharp
|
||||
@page "/products"
|
||||
@page "/products/{id}"
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr;">
|
||||
<ProductList OnSelectProduct="@SelectProduct" />
|
||||
@if (selectedId != null)
|
||||
{
|
||||
<ProductDetail Id="@selectedId" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? id { get; set; }
|
||||
|
||||
private string? selectedId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
selectedId = id;
|
||||
}
|
||||
|
||||
private void SelectProduct(string productId)
|
||||
{
|
||||
Navigation.NavigateTo($"/products/{productId}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Breadcrumb Navigation
|
||||
|
||||
```csharp
|
||||
@page "/category/{categoryId}/product/{productId}"
|
||||
|
||||
<div class="breadcrumb">
|
||||
<a href="/">Home</a> /
|
||||
<a href="/category/@categoryId">@categoryName</a> /
|
||||
<span>@productName</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? categoryId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? productId { get; set; }
|
||||
|
||||
private string? categoryName;
|
||||
private string? productName;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
categoryName = await LoadCategoryAsync(categoryId);
|
||||
productName = await LoadProductAsync(productId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tab-Based Navigation
|
||||
|
||||
```csharp
|
||||
@page "/settings"
|
||||
|
||||
<div class="tabs">
|
||||
<NavLink href="/settings/profile" Match="NavLinkMatch.All">Profile</NavLink>
|
||||
<NavLink href="/settings/security" Match="NavLinkMatch.All">Security</NavLink>
|
||||
<NavLink href="/settings/notifications" Match="NavLinkMatch.All">Notifications</NavLink>
|
||||
</div>
|
||||
|
||||
<Router AppAssembly="@typeof(App).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(SettingsLayout)" />
|
||||
</Found>
|
||||
</Router>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [components-lifecycle.md](components-lifecycle.md) for parameter handling. See [authentication-authorization.md](authentication-authorization.md) for route authorization.
|
||||
@@ -0,0 +1,575 @@
|
||||
# Blazor State Management & Events
|
||||
|
||||
## Component State
|
||||
|
||||
State represents the data that a component manages and renders.
|
||||
|
||||
### Local Component State
|
||||
|
||||
```csharp
|
||||
@page "/counter"
|
||||
|
||||
<p>Count: @count</p>
|
||||
<button @onclick="Increment">Click me</button>
|
||||
|
||||
@code {
|
||||
private int count = 0;
|
||||
|
||||
private void Increment()
|
||||
{
|
||||
count++;
|
||||
// Re-render happens automatically after event handler
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Blazor detects state change during event handler execution
|
||||
- Automatically calls `StateHasChanged()` after handler completes
|
||||
- Component re-renders with new state
|
||||
|
||||
### StateHasChanged() for External Updates
|
||||
|
||||
When state updates from outside Blazor's event system, call `StateHasChanged()` explicitly:
|
||||
|
||||
```csharp
|
||||
@implements IDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
private string? externalData;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Subscribe to external event
|
||||
JS.InvokeVoidAsync("subscribeToEvent", DotNetObjectReference.Create(this));
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void NotifyUpdate(string data)
|
||||
{
|
||||
externalData = data;
|
||||
// Blazor doesn't know about JS update, must call explicitly
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Clean up external subscriptions
|
||||
}
|
||||
```
|
||||
|
||||
### Thread-Safe State Updates with InvokeAsync()
|
||||
|
||||
When updating state from background threads (timers, async tasks outside event handlers):
|
||||
|
||||
```csharp
|
||||
@implements IDisposable
|
||||
|
||||
private Timer? timer;
|
||||
private int count = 0;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Timer running on background thread
|
||||
timer = new Timer(_ => UpdateCount(), null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
private void UpdateCount()
|
||||
{
|
||||
// WRONG - can't update state from background thread directly
|
||||
// count++;
|
||||
|
||||
// CORRECT - use InvokeAsync to marshal to UI thread
|
||||
InvokeAsync(() =>
|
||||
{
|
||||
count++;
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
timer?.Dispose();
|
||||
}
|
||||
```
|
||||
|
||||
### State Immutability Pattern
|
||||
|
||||
For complex state (objects, lists), follow immutability pattern:
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
private List<Item> items = [];
|
||||
|
||||
// WRONG - mutates in place, may not trigger re-render
|
||||
private void AddItem()
|
||||
{
|
||||
items.Add(new Item { Name = "New" });
|
||||
}
|
||||
|
||||
// CORRECT - create new collection
|
||||
private void AddItem()
|
||||
{
|
||||
items = items.Append(new Item { Name = "New" }).ToList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Event Handling
|
||||
|
||||
### Basic Click Handler
|
||||
|
||||
```csharp
|
||||
<button @onclick="HandleClick">Click me</button>
|
||||
|
||||
@code {
|
||||
private void HandleClick()
|
||||
{
|
||||
// Event handler logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### EventCallback Pattern (Recommended)
|
||||
|
||||
EventCallback is the proper way to notify parent components of events:
|
||||
|
||||
```csharp
|
||||
<!-- Child component -->
|
||||
<button @onclick="OnClick">Click me</button>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public EventCallback<string> OnValueChanged { get; set; }
|
||||
|
||||
private async Task OnClick()
|
||||
{
|
||||
await OnValueChanged.InvokeAsync("New Value");
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Parent component -->
|
||||
<ChildComponent OnValueChanged="@HandleValueChanged" />
|
||||
|
||||
@code {
|
||||
private void HandleValueChanged(string value)
|
||||
{
|
||||
// Handle value change
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### EventCallback with Arguments
|
||||
|
||||
```csharp
|
||||
<!-- Child -->
|
||||
<button @onclick="NotifyParent">Send Data</button>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public EventCallback<CustomArgs> OnDataChanged { get; set; }
|
||||
|
||||
private async Task NotifyParent()
|
||||
{
|
||||
var args = new CustomArgs { Id = 123, Name = "Test" };
|
||||
await OnValueChanged.InvokeAsync(args);
|
||||
}
|
||||
}
|
||||
|
||||
public class CustomArgs
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
<!-- Parent -->
|
||||
<ChildComponent OnDataChanged="@(args => HandleData(args.Id, args.Name))" />
|
||||
|
||||
@code {
|
||||
private void HandleData(int id, string? name)
|
||||
{
|
||||
// Process data
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Event Handlers
|
||||
|
||||
Always use async properly with EventCallback:
|
||||
|
||||
```csharp
|
||||
<!-- Good - async handler, proper awaiting -->
|
||||
<button @onclick="SaveAsync">Save</button>
|
||||
|
||||
@code {
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
isLoading = true;
|
||||
try
|
||||
{
|
||||
await Service.SaveDataAsync(data);
|
||||
successMessage = "Saved!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Event Handlers
|
||||
|
||||
```csharp
|
||||
<!-- Click -->
|
||||
<button @onclick="HandleClick">Click</button>
|
||||
|
||||
<!-- Double click -->
|
||||
<div @ondblclick="HandleDoubleClick">Double click</div>
|
||||
|
||||
<!-- Focus/Blur -->
|
||||
<input @onfocus="HandleFocus" @onblur="HandleBlur" />
|
||||
|
||||
<!-- Key events -->
|
||||
<input @onkeydown="HandleKeyDown" @onkeyup="HandleKeyUp" />
|
||||
|
||||
<!-- Mouse events -->
|
||||
<div @onmouseover="HandleMouseOver" @onmouseout="HandleMouseOut" />
|
||||
|
||||
<!-- Change -->
|
||||
<select @onchange="HandleChange">
|
||||
<option>Option 1</option>
|
||||
</select>
|
||||
|
||||
<!-- Submit -->
|
||||
<form @onsubmit="HandleSubmit">
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### preventDefault and stopPropagation
|
||||
|
||||
```csharp
|
||||
<!-- Prevent form submission -->
|
||||
<form @onsubmit:preventDefault="true" @onsubmit="HandleSubmit">
|
||||
<input type="text" />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
<!-- Stop event propagation -->
|
||||
<div @onclick="ParentClick">
|
||||
<button @onclick="ChildClick" @onclick:stopPropagation="true">
|
||||
Click - won't bubble
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Data Binding
|
||||
|
||||
### Two-Way Binding (@bind)
|
||||
|
||||
```csharp
|
||||
<input @bind="name" />
|
||||
<p>You entered: @name</p>
|
||||
|
||||
@code {
|
||||
private string name = "";
|
||||
}
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- `@bind` = `@bind-value` + `@bind-value:event="onchange"`
|
||||
- Sets value property, listens to onchange event
|
||||
- Automatic two-way synchronization
|
||||
|
||||
### Custom Events with @bind
|
||||
|
||||
```csharp
|
||||
<input @bind="value" @bind:event="oninput" />
|
||||
|
||||
@code {
|
||||
private string value = "";
|
||||
}
|
||||
```
|
||||
|
||||
Events: `onchange` (default), `oninput` (real-time), `onblur`, etc.
|
||||
|
||||
### Numeric Binding
|
||||
|
||||
```csharp
|
||||
<input @bind="age" @bind:culture="CultureInfo.InvariantCulture" />
|
||||
|
||||
@code {
|
||||
private int age = 0;
|
||||
}
|
||||
```
|
||||
|
||||
### DateTime Binding
|
||||
|
||||
```csharp
|
||||
<input type="date" @bind="date" />
|
||||
<input type="datetime-local" @bind="dateTime" />
|
||||
|
||||
@code {
|
||||
private DateOnly date = DateOnly.FromDateTime(DateTime.Now);
|
||||
private DateTime dateTime = DateTime.Now;
|
||||
}
|
||||
```
|
||||
|
||||
### Binding with Format Specifiers
|
||||
|
||||
```csharp
|
||||
<input @bind="price" @bind:format="N2" />
|
||||
<p>Price: @price.ToString("C")</p>
|
||||
|
||||
@code {
|
||||
private decimal price = 0;
|
||||
}
|
||||
```
|
||||
|
||||
### Bind Modifiers
|
||||
|
||||
```csharp
|
||||
<!-- @bind:get / @bind:set for custom logic -->
|
||||
<input @bind="@value"
|
||||
@bind:get="parsedValue"
|
||||
@bind:set="@SetValue" />
|
||||
|
||||
@code {
|
||||
private string value = "";
|
||||
|
||||
private string parsedValue
|
||||
{
|
||||
get => value.ToUpper();
|
||||
}
|
||||
|
||||
private void SetValue(string val)
|
||||
{
|
||||
value = val.ToLower();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cascading Values with Events
|
||||
|
||||
Provide shared state and event callbacks to child components:
|
||||
|
||||
```csharp
|
||||
<!-- Parent - AppState provider -->
|
||||
<CascadingValue Value="@appState">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
|
||||
@code {
|
||||
private AppState appState = new();
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
|
||||
<!-- AppState service -->
|
||||
public class AppState
|
||||
{
|
||||
private string _username = "";
|
||||
public event Action? OnChange;
|
||||
|
||||
public string Username
|
||||
{
|
||||
get => _username;
|
||||
set
|
||||
{
|
||||
if (_username != value)
|
||||
{
|
||||
_username = value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyStateChanged() => OnChange?.Invoke();
|
||||
}
|
||||
|
||||
<!-- Child component - subscribe to state changes -->
|
||||
@implements IDisposable
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public AppState? AppState { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (AppState != null)
|
||||
{
|
||||
AppState.OnChange += StateHasChanged;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (AppState != null)
|
||||
{
|
||||
AppState.OnChange -= StateHasChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Service-Based State Management
|
||||
|
||||
For application-wide state, use services:
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
builder.Services.AddScoped<AppState>();
|
||||
|
||||
// AppState service
|
||||
public class AppState
|
||||
{
|
||||
private string _theme = "light";
|
||||
private User? _currentUser;
|
||||
|
||||
public event Func<Task>? OnStateChange;
|
||||
|
||||
public string Theme
|
||||
{
|
||||
get => _theme;
|
||||
set
|
||||
{
|
||||
if (_theme != value)
|
||||
{
|
||||
_theme = value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public User? CurrentUser
|
||||
{
|
||||
get => _currentUser;
|
||||
set
|
||||
{
|
||||
if (_currentUser != value)
|
||||
{
|
||||
_currentUser = value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifyStateChanged()
|
||||
{
|
||||
if (OnStateChange != null)
|
||||
{
|
||||
await OnStateChange.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Component using AppState
|
||||
@inject AppState AppState
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AppState.OnStateChange += StateHasChanged;
|
||||
AppState.CurrentUser = await LoadUserAsync();
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
if (AppState != null)
|
||||
{
|
||||
AppState.OnStateChange -= StateHasChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Parent-Child Communication Pattern
|
||||
|
||||
**Data flow:** Parents pass data DOWN via parameters, children notify UP via events.
|
||||
|
||||
```csharp
|
||||
<!-- Parent -->
|
||||
@page "/parent"
|
||||
|
||||
<h2>Parent: @selectedId</h2>
|
||||
<Child SelectedId="@selectedId"
|
||||
OnIdChanged="@HandleIdChanged" />
|
||||
|
||||
@code {
|
||||
private int selectedId = 0;
|
||||
|
||||
private async Task HandleIdChanged(int newId)
|
||||
{
|
||||
selectedId = newId;
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Child -->
|
||||
<select @onchange="OnSelectionChanged">
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
<option value="@item.Id">@item.Name</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int SelectedId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> OnIdChanged { get; set; }
|
||||
|
||||
private List<Item> Items { get; set; } = [];
|
||||
|
||||
private async Task OnSelectionChanged(ChangeEventArgs args)
|
||||
{
|
||||
var newId = int.Parse(args.Value?.ToString() ?? "0");
|
||||
await OnIdChanged.InvokeAsync(newId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Always Use EventCallback
|
||||
- ✅ `[Parameter] public EventCallback OnEvent { get; set; }`
|
||||
- ❌ `[Parameter] public Action? OnEvent { get; set; }`
|
||||
|
||||
EventCallback handles async properly and integrates better with Blazor's rendering pipeline.
|
||||
|
||||
### Keep Event Handlers Focused
|
||||
- Do one thing per handler
|
||||
- Move complex logic to services
|
||||
- Keep components as thin view layers
|
||||
|
||||
### Unsubscribe from Events
|
||||
Always clean up subscriptions to prevent memory leaks:
|
||||
|
||||
```csharp
|
||||
@implements IDisposable
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Service.OnChange += HandleChange;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Service.OnChange -= HandleChange;
|
||||
}
|
||||
```
|
||||
|
||||
### Use Immutable Updates
|
||||
- Create new objects/collections for state updates
|
||||
- Don't mutate objects in place
|
||||
- Helps with change detection and debugging
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [components-lifecycle.md](components-lifecycle.md) for component parameters and cascading values. See [forms-validation.md](forms-validation.md) for form event handling.
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
name: nexus-architecture-standards
|
||||
description: Guidelines and automated checks for maintaining Clean Architecture and SaaS standards in the NexusReader project.
|
||||
tags: [Architecture, CleanArchitecture, .NET, MediatR, SaaS, MultiTenancy]
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# NexusReader Architecture Standards
|
||||
|
||||
This skill defines the architectural guardrails for the NexusReader project to ensure consistency, scalability, and security.
|
||||
|
||||
## Core Rules
|
||||
|
||||
### 1. Clean Architecture Layers
|
||||
- **Domain**: Pure business logic, entities, and enums. Zero dependencies on other layers.
|
||||
- **Application**: Use cases, MediatR handlers, and interfaces. Depends ONLY on Domain.
|
||||
- **Infrastructure**: Implementation details (DB context, AI services, Auth). Depends on Application and Domain.
|
||||
- **Web/Mobile**: Presentation layer. Depends on Application (and Infrastructure for DI setup).
|
||||
|
||||
> [!CAUTION]
|
||||
> **Application MUST NOT depend on Infrastructure.** This is a common failure mode. Always use abstractions (interfaces) in Application and implement them in Infrastructure.
|
||||
|
||||
### 2. Multi-Tenancy (Tenant Isolation)
|
||||
- Every entity related to user data MUST have a `TenantId` property.
|
||||
- Every query MUST filter by `TenantId` to prevent data leakage.
|
||||
- Default `TenantId` is "global" for shared resources.
|
||||
|
||||
### 3. Error Handling
|
||||
- Use `FluentResults` (`Result<T>`) for all Application services and handlers.
|
||||
- Avoid throwing exceptions for expected business failures; use `Result.Fail()`.
|
||||
|
||||
- **Commands**: State-changing operations. Should return `Result` or `Result<T>`.
|
||||
+
|
||||
+### 5. Async Operations (Zero Tolerance for `async void`)
|
||||
+- All asynchronous operations MUST return `Task` or `ValueTask`.
|
||||
+- Event handlers MUST use `Func<Task>` or async-compatible patterns.
|
||||
+- UI components MUST await all service calls and use `InvokeAsync(StateHasChanged)` for state updates within async contexts.
|
||||
|
||||
## Audit Scripts
|
||||
- [ArchCheck.sh](scripts/arch_check.sh): A shell script to scan for illegal cross-layer imports.
|
||||
|
||||
## Reference Materials
|
||||
- [Layer Dependency Matrix](artifacts/layer_matrix.md)
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Simple script to check for Clean Architecture violations in NexusReader
|
||||
|
||||
APP_DIR="src/NexusReader.Application"
|
||||
VIOLATIONS=$(grep -r "using NexusReader.Infrastructure" "$APP_DIR")
|
||||
|
||||
if [ -n "$VIOLATIONS" ]; then
|
||||
echo "ERROR: Clean Architecture violations found in $APP_DIR:"
|
||||
echo "$VIOLATIONS"
|
||||
exit 1
|
||||
else
|
||||
echo "SUCCESS: No illegal Infrastructure dependencies found in Application layer."
|
||||
exit 0
|
||||
fi
|
||||
@@ -28,3 +28,6 @@ When conducting or receiving a code review for NexusReader, ensure the implement
|
||||
## 5. Standard Nexus Guidelines
|
||||
- [ ] **Result Pattern**: Ensure all application logic returns `Result` or `Result<T>` via FluentResults. No exceptions for control flow.
|
||||
- [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`.
|
||||
|
||||
## 6. Code Review Comments
|
||||
- [ ] **Specific Linking**: Comments should be linked to specific code. Try to avoid general comments about the entire pull request.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: nexus-dotnet-architect
|
||||
description: Guides the development of production-grade .NET 10 APIs and microservices for the Nexus project, enforcing Clean Architecture, CQRS, Result Pattern, Mapster, no async void, specific project standards like Multi-Tenancy and EF Core migrations, and backend development best practices like caching, resilience, observability, and AI-powered code analysis. Use when building backend services or APIs within the Nexus ecosystem.
|
||||
---
|
||||
|
||||
# Nexus Dotnet Architect Skill
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides expert guidance for developing production-grade .NET 10 APIs and microservices within the Nexus project ecosystem. It enforces a strict adherence to the defined architecture, technical constraints, and development workflow, ensuring high performance, maintainability, and scalability.
|
||||
|
||||
## Core Principles & Constraints
|
||||
|
||||
This skill mandates the following architectural and development standards:
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Clean Architecture:** Strict separation of concerns: `Domain` -> `Application` <- `Infrastructure`.
|
||||
- **CQRS Pattern:** Mandatory use of `MediatR`. All business logic must reside in handlers, not UI components.
|
||||
- **Result Pattern:** Zero exceptions for flow control. All handlers must return `Result<T>` via `FluentResult`.
|
||||
- **Mapping:** Exclusive use of `Mapster`. No other mapping libraries are permitted.
|
||||
|
||||
### Technical Constraints
|
||||
|
||||
- **Platform:** Target .NET 10 with **Native AOT** compatibility. Optimize for mobile performance.
|
||||
- **UI Framework:** Blazor Component Model. Use isolated Razor Components (`.razor` + `.razor.css`). No raw HTML/CSS in components.
|
||||
- **Directory Structure:** `/src` for application code and `/tests` for testing code at the solution root level.
|
||||
|
||||
### Development Workflow
|
||||
|
||||
- **Verification-Led:** Define tests and verification steps *before* writing feature code.
|
||||
- **Step-by-Step Execution:** Break complex tasks into manageable, verifiable chunks.
|
||||
- **Layer Integrity:** Constantly check for and prevent illegal cross-layer dependencies (e.g., `Application` depending on `Infrastructure`).
|
||||
- **Mandatory Build Gate:** After **every** code change, run `dotnet build NexusReader.slnx --no-restore` from the solution root. The agent must not proceed if there are any `error CS*` compiler errors. Build warnings are acceptable.
|
||||
|
||||
### API & Microservice Focus
|
||||
|
||||
- Develop production-grade APIs and microservices using C# and ASP.NET Core.
|
||||
- Leverage modern C# features.
|
||||
- Implement robust data access patterns, including EF Core and Dapper.
|
||||
- Incorporate caching strategies and performance optimization.
|
||||
|
||||
## Project Specific Standards
|
||||
|
||||
### Multi-Tenancy (Tenant Isolation)
|
||||
|
||||
- Every entity related to user data MUST have a `TenantId` property.
|
||||
- Every query MUST filter by `TenantId` to prevent data leakage.
|
||||
- Default `TenantId` is "global" for shared resources.
|
||||
|
||||
### Database Schema Changes
|
||||
|
||||
- Every change to a Domain entity or DbContext MUST be followed by the generation of a new EF Core migration.
|
||||
- **Mandatory Commands**:
|
||||
- `dotnet ef migrations add <MigrationName> --project src/NexusReader.Data --startup-project src/NexusReader.Web`
|
||||
- `dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web`
|
||||
- Ensure the migration is applied to all local development environments before proceeding with feature verification.
|
||||
|
||||
### Auditing & Verification
|
||||
|
||||
- **Audit Scripts:** Use `src/.agent/skills/nexus-architecture-standards/scripts/arch_check.sh` (or equivalent logic) to scan for illegal cross-layer imports. This script should be run regularly to maintain layer integrity.
|
||||
- **Reference Materials:** Refer to `src/.agent/skills/nexus-architecture-standards/artifacts/layer_matrix.md` for a clear definition of layer dependencies.
|
||||
|
||||
## Backend Development Patterns
|
||||
|
||||
### Architecture & Design
|
||||
|
||||
- **API Design:** Follow RESTful principles, use clear and consistent naming conventions.
|
||||
- **Microservices Principles:** Design for independent deployability, scalability, and fault isolation.
|
||||
- **Domain-Driven Design (DDD):** Apply DDD concepts where appropriate to model complex business domains.
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
- Utilize the built-in .NET Core Dependency Injection container.
|
||||
- Register services with appropriate lifetimes (Scoped, Singleton, Transient).
|
||||
- Prefer constructor injection.
|
||||
|
||||
### Caching
|
||||
|
||||
- Implement distributed caching using **Redis** for improved performance and reduced database load.
|
||||
- Apply caching strategies judiciously (e.g., cache-aside, read-through, write-through).
|
||||
|
||||
### Database Optimization
|
||||
|
||||
- **Entity Framework Core (EF Core):** Optimize queries, use `AsNoTracking()`, leverage projections, and manage migrations effectively.
|
||||
- **Dapper:** Utilize Dapper for performance-critical queries where EF Core might be too slow.
|
||||
- **Connection Pooling:** Ensure database connections are managed efficiently.
|
||||
|
||||
### Resilience Patterns
|
||||
|
||||
- **Retry Policies:** Implement retry logic for transient failures (e.g., network issues, temporary service unavailability) using libraries like Polly.
|
||||
- **Circuit Breaker:** Protect against cascading failures by implementing circuit breaker patterns.
|
||||
- **Timeouts:** Configure appropriate timeouts for external service calls and database operations.
|
||||
|
||||
### Observability
|
||||
|
||||
- **Logging:** Implement structured logging using a robust logging framework (e.g., Serilog).
|
||||
- **Monitoring:** Integrate with monitoring solutions (e.g., Application Insights, Prometheus) for metrics and performance tracking.
|
||||
- **Distributed Tracing:** Enable distributed tracing to track requests across multiple services.
|
||||
|
||||
## Code Review & Quality Assurance
|
||||
|
||||
### Static Analysis
|
||||
|
||||
- Scan code for common bugs, anti-patterns, and style violations.
|
||||
- Ensure adherence to project coding standards.
|
||||
|
||||
### Security Review (OWASP)
|
||||
|
||||
- Identify potential security vulnerabilities based on OWASP Top 10 guidelines.
|
||||
- Check for common security flaws like injection vulnerabilities, broken authentication, etc.
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
- Analyze code for performance bottlenecks.
|
||||
- Suggest improvements for efficiency and resource utilization.
|
||||
|
||||
### Infrastructure-as-Code (IaC) Assessment
|
||||
|
||||
- Review IaC definitions (e.g., Terraform, Dockerfile) for security and best practices.
|
||||
|
||||
## Review Process
|
||||
|
||||
The code reviewer follows a structured, 10-step approach to provide feedback:
|
||||
|
||||
1. **Understand Context:** Analyze the code and its purpose.
|
||||
2. **Static Analysis:** Perform initial checks for common issues.
|
||||
3. **Security Scan:** Identify potential security vulnerabilities.
|
||||
4. **Performance Check:** Evaluate for performance bottlenecks.
|
||||
5. **IaC Review:** Assess infrastructure code if applicable.
|
||||
6. **Best Practices Check:** Verify adherence to established patterns.
|
||||
7. **Constructive Feedback:** Provide clear, actionable suggestions.
|
||||
8. **Prioritization:** Rank feedback by severity (critical, high, medium, low).
|
||||
9. **Educational Tone:** Explain *why* a change is recommended.
|
||||
10. **Final Summary:** Consolidate findings and recommendations.
|
||||
|
||||
## Resources
|
||||
|
||||
- **EF Core Best Practices:** See `references/ef-core-best-practices.md` for detailed guidance on optimizing EF Core usage.
|
||||
- **Implementation Playbook:** Refer to `resources/implementation-playbook.md` for detailed examples and implementation guidance.
|
||||
@@ -0,0 +1,355 @@
|
||||
# Entity Framework Core Best Practices
|
||||
|
||||
Performance optimization and best practices for EF Core in production applications.
|
||||
|
||||
## Query Optimization
|
||||
|
||||
### 1. Use AsNoTracking for Read-Only Queries
|
||||
|
||||
```csharp
|
||||
// ✅ Good - No change tracking overhead
|
||||
var products = await _context.Products
|
||||
.AsNoTracking()
|
||||
.Where(p => p.CategoryId == categoryId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// ❌ Bad - Unnecessary tracking for read-only data
|
||||
var products = await _context.Products
|
||||
.Where(p => p.CategoryId == categoryId)
|
||||
.ToListAsync(ct);
|
||||
```
|
||||
|
||||
### 2. Select Only Needed Columns
|
||||
|
||||
```csharp
|
||||
// ✅ Good - Project to DTO
|
||||
var products = await _context.Products
|
||||
.AsNoTracking()
|
||||
.Where(p => p.CategoryId == categoryId)
|
||||
.Select(p => new ProductDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Price = p.Price
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
// ❌ Bad - Fetching all columns
|
||||
var products = await _context.Products
|
||||
.Where(p => p.CategoryId == categoryId)
|
||||
.ToListAsync(ct);
|
||||
```
|
||||
|
||||
### 3. Avoid N+1 Queries with Eager Loading
|
||||
|
||||
```csharp
|
||||
// ✅ Good - Single query with Include
|
||||
var orders = await _context.Orders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(i => i.Product)
|
||||
.Where(o => o.CustomerId == customerId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// ❌ Bad - N+1 queries (lazy loading)
|
||||
var orders = await _context.Orders
|
||||
.Where(o => o.CustomerId == customerId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var order in orders)
|
||||
{
|
||||
// Each iteration triggers a separate query!
|
||||
var items = order.Items.ToList();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use Split Queries for Large Includes
|
||||
|
||||
```csharp
|
||||
// ✅ Good - Prevents cartesian explosion
|
||||
var orders = await _context.Orders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Items)
|
||||
.Include(o => o.Payments)
|
||||
.Include(o => o.ShippingHistory)
|
||||
.AsSplitQuery() // Executes as multiple queries
|
||||
.Where(o => o.CustomerId == customerId)
|
||||
.ToListAsync(ct);
|
||||
```
|
||||
|
||||
### 5. Use Compiled Queries for Hot Paths
|
||||
|
||||
```csharp
|
||||
public class ProductRepository
|
||||
{
|
||||
// Compile once, reuse many times
|
||||
private static readonly Func<AppDbContext, string, Task<Product?>> GetByIdQuery =
|
||||
EF.CompileAsyncQuery((AppDbContext ctx, string id) =>
|
||||
ctx.Products.AsNoTracking().FirstOrDefault(p => p.Id == id));
|
||||
|
||||
private static readonly Func<AppDbContext, int, IAsyncEnumerable<Product>> GetByCategoryQuery =
|
||||
EF.CompileAsyncQuery((AppDbContext ctx, int categoryId) =>
|
||||
ctx.Products.AsNoTracking().Where(p => p.CategoryId == categoryId));
|
||||
|
||||
public Task<Product?> GetByIdAsync(string id, CancellationToken ct)
|
||||
=> GetByIdQuery(_context, id);
|
||||
|
||||
public IAsyncEnumerable<Product> GetByCategoryAsync(int categoryId)
|
||||
=> GetByCategoryQuery(_context, categoryId);
|
||||
}
|
||||
```
|
||||
|
||||
## Batch Operations
|
||||
|
||||
### 6. Use ExecuteUpdate/ExecuteDelete (.NET 7+)
|
||||
|
||||
```csharp
|
||||
// ✅ Good - Single SQL UPDATE
|
||||
await _context.Products
|
||||
.Where(p => p.CategoryId == oldCategoryId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(p => p.CategoryId, newCategoryId)
|
||||
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow),
|
||||
ct);
|
||||
|
||||
// ✅ Good - Single SQL DELETE
|
||||
await _context.Products
|
||||
.Where(p => p.IsDeleted && p.UpdatedAt < cutoffDate)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
// ❌ Bad - Loads all entities into memory
|
||||
var products = await _context.Products
|
||||
.Where(p => p.CategoryId == oldCategoryId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var product in products)
|
||||
{
|
||||
product.CategoryId = newCategoryId;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
```
|
||||
|
||||
### 7. Bulk Insert with EFCore.BulkExtensions
|
||||
|
||||
```csharp
|
||||
// Using EFCore.BulkExtensions package
|
||||
var products = GenerateLargeProductList();
|
||||
|
||||
// ✅ Good - Bulk insert (much faster for large datasets)
|
||||
await _context.BulkInsertAsync(products, ct);
|
||||
|
||||
// ❌ Bad - Individual inserts
|
||||
foreach (var product in products)
|
||||
{
|
||||
_context.Products.Add(product);
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
```
|
||||
|
||||
## Connection Management
|
||||
|
||||
### 8. Configure Connection Pooling
|
||||
|
||||
```csharp
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
{
|
||||
options.UseSqlServer(connectionString, sqlOptions =>
|
||||
{
|
||||
sqlOptions.EnableRetryOnFailure(
|
||||
maxRetryCount: 3,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(10),
|
||||
errorNumbersToAdd: null);
|
||||
|
||||
sqlOptions.CommandTimeout(30);
|
||||
});
|
||||
|
||||
// Performance settings
|
||||
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
|
||||
|
||||
// Development only
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
options.EnableSensitiveDataLogging();
|
||||
options.EnableDetailedErrors();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 9. Use DbContext Pooling
|
||||
|
||||
```csharp
|
||||
// ✅ Good - Context pooling (reduces allocation overhead)
|
||||
services.AddDbContextPool<AppDbContext>(options =>
|
||||
{
|
||||
options.UseSqlServer(connectionString);
|
||||
}, poolSize: 128);
|
||||
|
||||
// Instead of AddDbContext
|
||||
```
|
||||
|
||||
## Concurrency and Transactions
|
||||
|
||||
### 10. Handle Concurrency with Row Versioning
|
||||
|
||||
```csharp
|
||||
public class Product
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
|
||||
[Timestamp]
|
||||
public byte[] RowVersion { get; set; } // SQL Server rowversion
|
||||
}
|
||||
|
||||
// Or with Fluent API
|
||||
builder.Property(p => p.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
||||
// Handle concurrency conflicts
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException ex)
|
||||
{
|
||||
var entry = ex.Entries.Single();
|
||||
var databaseValues = await entry.GetDatabaseValuesAsync(ct);
|
||||
|
||||
if (databaseValues == null)
|
||||
{
|
||||
// Entity was deleted
|
||||
throw new NotFoundException("Product was deleted by another user");
|
||||
}
|
||||
|
||||
// Client wins - overwrite database values
|
||||
entry.OriginalValues.SetValues(databaseValues);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
```
|
||||
|
||||
### 11. Use Explicit Transactions When Needed
|
||||
|
||||
```csharp
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Multiple operations
|
||||
_context.Orders.Add(order);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
|
||||
await _context.OrderItems.AddRangeAsync(items, ct);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
|
||||
await _paymentService.ProcessAsync(order.Id, ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
```
|
||||
|
||||
## Indexing Strategy
|
||||
|
||||
### 12. Create Indexes for Query Patterns
|
||||
|
||||
```csharp
|
||||
public class ProductConfiguration : IEntityTypeConfiguration<Product>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Product> builder)
|
||||
{
|
||||
// Unique index
|
||||
builder.HasIndex(p => p.Sku)
|
||||
.IsUnique();
|
||||
|
||||
// Composite index for common query patterns
|
||||
builder.HasIndex(p => new { p.CategoryId, p.Name });
|
||||
|
||||
// Filtered index (SQL Server)
|
||||
builder.HasIndex(p => p.Price)
|
||||
.HasFilter("[IsDeleted] = 0");
|
||||
|
||||
// Include columns for covering index
|
||||
builder.HasIndex(p => p.CategoryId)
|
||||
.IncludeProperties(p => new { p.Name, p.Price });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Anti-Patterns to Avoid
|
||||
|
||||
### ❌ Calling ToList() Too Early
|
||||
|
||||
```csharp
|
||||
// ❌ Bad - Materializes all products then filters in memory
|
||||
var products = _context.Products.ToList()
|
||||
.Where(p => p.Price > 100);
|
||||
|
||||
// ✅ Good - Filter in SQL
|
||||
var products = await _context.Products
|
||||
.Where(p => p.Price > 100)
|
||||
.ToListAsync(ct);
|
||||
```
|
||||
|
||||
### ❌ Using Contains with Large Collections
|
||||
|
||||
```csharp
|
||||
// ❌ Bad - Generates massive IN clause
|
||||
var ids = GetThousandsOfIds();
|
||||
var products = await _context.Products
|
||||
.Where(p => ids.Contains(p.Id))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// ✅ Good - Use temp table or batch queries
|
||||
var products = new List<Product>();
|
||||
foreach (var batch in ids.Chunk(100))
|
||||
{
|
||||
var batchResults = await _context.Products
|
||||
.Where(p => batch.Contains(p.Id))
|
||||
.ToListAsync(ct);
|
||||
products.AddRange(batchResults);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ String Concatenation in Queries
|
||||
|
||||
```csharp
|
||||
// ❌ Bad - Can't use index
|
||||
var products = await _context.Products
|
||||
.Where(p => (p.FirstName + " " + p.LastName).Contains(searchTerm))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// ✅ Good - Use computed column with index
|
||||
builder.Property(p => p.FullName)
|
||||
.HasComputedColumnSql("[FirstName] + ' ' + [LastName]");
|
||||
builder.HasIndex(p => p.FullName);
|
||||
```
|
||||
|
||||
## Monitoring and Diagnostics
|
||||
|
||||
```csharp
|
||||
// Log slow queries
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
{
|
||||
options.UseSqlServer(connectionString);
|
||||
|
||||
options.LogTo(
|
||||
filter: (eventId, level) => eventId.Id == CoreEventId.QueryExecutionPlanned.Id,
|
||||
logger: (eventData) =>
|
||||
{
|
||||
if (eventData is QueryExpressionEventData queryData)
|
||||
{
|
||||
var duration = queryData.Duration;
|
||||
if (duration > TimeSpan.FromSeconds(1))
|
||||
{
|
||||
_logger.LogWarning("Slow query detected: {Duration}ms - {Query}",
|
||||
duration.TotalMilliseconds,
|
||||
queryData.Expression);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,801 @@
|
||||
# .NET Backend Development Patterns Implementation Playbook
|
||||
|
||||
This file contains detailed patterns, checklists, and code samples referenced by the skill.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Project Structure (Clean Architecture)
|
||||
|
||||
```
|
||||
src/
|
||||
├── Domain/ # Core business logic (no dependencies)
|
||||
│ ├── Entities/
|
||||
│ ├── Interfaces/
|
||||
│ ├── Exceptions/
|
||||
│ └── ValueObjects/
|
||||
├── Application/ # Use cases, DTOs, validation
|
||||
│ ├── Services/
|
||||
│ ├── DTOs/
|
||||
│ ├── Validators/
|
||||
│ └── Interfaces/
|
||||
├── Infrastructure/ # External implementations
|
||||
│ ├── Data/ # EF Core, Dapper repositories
|
||||
│ ├── Caching/ # Redis, Memory cache
|
||||
│ ├── External/ # HTTP clients, third-party APIs
|
||||
│ └── DependencyInjection/ # Service registration
|
||||
└── Api/ # Entry point
|
||||
├── Controllers/ # Or MinimalAPI endpoints
|
||||
├── Middleware/
|
||||
├── Filters/
|
||||
└── Program.cs
|
||||
```
|
||||
|
||||
### 2. Dependency Injection Patterns
|
||||
|
||||
```csharp
|
||||
// Service registration by lifetime
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddApplicationServices(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Scoped: One instance per HTTP request
|
||||
services.AddScoped<IProductService, ProductService>();
|
||||
services.AddScoped<IOrderService, OrderService>();
|
||||
|
||||
// Singleton: One instance for app lifetime
|
||||
services.AddSingleton<ICacheService, RedisCacheService>();
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||
ConnectionMultiplexer.Connect(configuration["Redis:Connection"]!));
|
||||
|
||||
// Transient: New instance every time
|
||||
services.AddTransient<IValidator<CreateOrderRequest>, CreateOrderValidator>();
|
||||
|
||||
// Options pattern for configuration
|
||||
services.Configure<CatalogOptions>(configuration.GetSection("Catalog"));
|
||||
services.Configure<RedisOptions>(configuration.GetSection("Redis"));
|
||||
|
||||
// Factory pattern for conditional creation
|
||||
services.AddScoped<IPriceCalculator>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<PricingOptions>>().Value;
|
||||
return options.UseNewEngine
|
||||
? sp.GetRequiredService<NewPriceCalculator>()
|
||||
: sp.GetRequiredService<LegacyPriceCalculator>();
|
||||
});
|
||||
|
||||
// Keyed services (.NET 8+)
|
||||
services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
|
||||
services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage with keyed services
|
||||
public class CheckoutService
|
||||
{
|
||||
public CheckoutService(
|
||||
[FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor)
|
||||
{
|
||||
_processor = stripeProcessor;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Async/Await Patterns
|
||||
|
||||
```csharp
|
||||
// ✅ CORRECT: Async all the way down
|
||||
public async Task<Product> GetProductAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
return await _repository.GetByIdAsync(id, ct);
|
||||
}
|
||||
|
||||
// ✅ CORRECT: Parallel execution with WhenAll
|
||||
public async Task<(Stock, Price)> GetStockAndPriceAsync(
|
||||
string productId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var stockTask = _stockService.GetAsync(productId, ct);
|
||||
var priceTask = _priceService.GetAsync(productId, ct);
|
||||
|
||||
await Task.WhenAll(stockTask, priceTask);
|
||||
|
||||
return (await stockTask, await priceTask);
|
||||
}
|
||||
|
||||
// ✅ CORRECT: ConfigureAwait in libraries
|
||||
public async Task<T> LibraryMethodAsync<T>(CancellationToken ct = default)
|
||||
{
|
||||
var result = await _httpClient.GetAsync(url, ct).ConfigureAwait(false);
|
||||
return await result.Content.ReadFromJsonAsync<T>(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ✅ CORRECT: ValueTask for hot paths with caching
|
||||
public ValueTask<Product?> GetCachedProductAsync(string id)
|
||||
{
|
||||
if (_cache.TryGetValue(id, out Product? product))
|
||||
return ValueTask.FromResult(product);
|
||||
|
||||
return new ValueTask<Product?>(GetFromDatabaseAsync(id));
|
||||
}
|
||||
|
||||
// ❌ WRONG: Blocking on async (deadlock risk)
|
||||
var result = GetProductAsync(id).Result; // NEVER do this
|
||||
var result2 = GetProductAsync(id).GetAwaiter().GetResult(); // Also bad
|
||||
|
||||
// ❌ WRONG: async void (except event handlers)
|
||||
public async void ProcessOrder() { } // Exceptions are lost
|
||||
|
||||
// ❌ WRONG: Unnecessary Task.Run for already async code
|
||||
await Task.Run(async () => await GetDataAsync()); // Wastes thread
|
||||
```
|
||||
|
||||
### 4. Configuration with IOptions
|
||||
|
||||
```csharp
|
||||
// Configuration classes
|
||||
public class CatalogOptions
|
||||
{
|
||||
public const string SectionName = "Catalog";
|
||||
|
||||
public int DefaultPageSize { get; set; } = 50;
|
||||
public int MaxPageSize { get; set; } = 200;
|
||||
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
public bool EnableEnrichment { get; set; } = true;
|
||||
}
|
||||
|
||||
public class RedisOptions
|
||||
{
|
||||
public const string SectionName = "Redis";
|
||||
|
||||
public string Connection { get; set; } = "localhost:6379";
|
||||
public string KeyPrefix { get; set; } = "mcp:";
|
||||
public int Database { get; set; } = 0;
|
||||
}
|
||||
|
||||
// appsettings.json
|
||||
{
|
||||
"Catalog": {
|
||||
"DefaultPageSize": 50,
|
||||
"MaxPageSize": 200,
|
||||
"CacheDuration": "00:15:00",
|
||||
"EnableEnrichment": true
|
||||
},
|
||||
"Redis": {
|
||||
"Connection": "localhost:6379",
|
||||
"KeyPrefix": "mcp:",
|
||||
"Database": 0
|
||||
}
|
||||
}
|
||||
|
||||
// Registration
|
||||
services.Configure<CatalogOptions>(configuration.GetSection(CatalogOptions.SectionName));
|
||||
services.Configure<RedisOptions>(configuration.GetSection(RedisOptions.SectionName));
|
||||
|
||||
// Usage with IOptions (singleton, read once at startup)
|
||||
public class CatalogService
|
||||
{
|
||||
private readonly CatalogOptions _options;
|
||||
|
||||
public CatalogService(IOptions<CatalogOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage with IOptionsSnapshot (scoped, re-reads on each request)
|
||||
public class DynamicService
|
||||
{
|
||||
private readonly CatalogOptions _options;
|
||||
|
||||
public DynamicService(IOptionsSnapshot<CatalogOptions> options)
|
||||
{
|
||||
_options = options.Value; // Fresh value per request
|
||||
}
|
||||
}
|
||||
|
||||
// Usage with IOptionsMonitor (singleton, notified on changes)
|
||||
public class MonitoredService
|
||||
{
|
||||
private CatalogOptions _options;
|
||||
|
||||
public MonitoredService(IOptionsMonitor<CatalogOptions> monitor)
|
||||
{
|
||||
_options = monitor.CurrentValue;
|
||||
monitor.OnChange(newOptions => _options = newOptions);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Result Pattern (Avoiding Exceptions for Flow Control)
|
||||
|
||||
```csharp
|
||||
// Generic Result type
|
||||
public class Result<T>
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public T? Value { get; }
|
||||
public string? Error { get; }
|
||||
public string? ErrorCode { get; }
|
||||
|
||||
private Result(bool isSuccess, T? value, string? error, string? errorCode)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
Value = value;
|
||||
Error = error;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public static Result<T> Success(T value) => new(true, value, null, null);
|
||||
public static Result<T> Failure(string error, string? code = null) => new(false, default, error, code);
|
||||
|
||||
public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
|
||||
IsSuccess ? Result<TNew>.Success(mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
|
||||
|
||||
public async Task<Result<TNew>> MapAsync<TNew>(Func<T, Task<TNew>> mapper) =>
|
||||
IsSuccess ? Result<TNew>.Success(await mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
|
||||
}
|
||||
|
||||
// Usage in service
|
||||
public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
|
||||
{
|
||||
// Validation
|
||||
var validation = await _validator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
return Result<Order>.Failure(
|
||||
validation.Errors.First().ErrorMessage,
|
||||
"VALIDATION_ERROR");
|
||||
|
||||
// Business rule check
|
||||
var stock = await _stockService.CheckAsync(request.ProductId, request.Quantity, ct);
|
||||
if (!stock.IsAvailable)
|
||||
return Result<Order>.Failure(
|
||||
$"Insufficient stock: {stock.Available} available, {request.Quantity} requested",
|
||||
"INSUFFICIENT_STOCK");
|
||||
|
||||
// Create order
|
||||
var order = await _repository.CreateAsync(request.ToEntity(), ct);
|
||||
|
||||
return Result<Order>.Success(order);
|
||||
}
|
||||
|
||||
// Usage in controller/endpoint
|
||||
app.MapPost("/orders", async (
|
||||
CreateOrderRequest request,
|
||||
IOrderService orderService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await orderService.CreateOrderAsync(request, ct);
|
||||
|
||||
return result.IsSuccess
|
||||
? Results.Created($"/orders/{result.Value!.Id}", result.Value)
|
||||
: Results.BadRequest(new { error = result.Error, code = result.ErrorCode });
|
||||
});
|
||||
```
|
||||
|
||||
## Data Access Patterns
|
||||
|
||||
### Entity Framework Core
|
||||
|
||||
```csharp
|
||||
// DbContext configuration
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public DbSet<Product> Products => Set<Product>();
|
||||
public DbSet<Order> Orders => Set<Order>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Apply all configurations from assembly
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
||||
|
||||
// Global query filters
|
||||
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
// Entity configuration
|
||||
public class ProductConfiguration : IEntityTypeConfiguration<Product>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Product> builder)
|
||||
{
|
||||
builder.ToTable("Products");
|
||||
|
||||
builder.HasKey(p => p.Id);
|
||||
builder.Property(p => p.Id).HasMaxLength(40);
|
||||
builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
|
||||
builder.Property(p => p.Price).HasPrecision(18, 2);
|
||||
|
||||
builder.HasIndex(p => p.Sku).IsUnique();
|
||||
builder.HasIndex(p => new { p.CategoryId, p.Name });
|
||||
|
||||
builder.HasMany(p => p.OrderItems)
|
||||
.WithOne(oi => oi.Product)
|
||||
.HasForeignKey(oi => oi.ProductId);
|
||||
}
|
||||
}
|
||||
|
||||
// Repository with EF Core
|
||||
public class ProductRepository : IProductRepository
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Products
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Product>> SearchAsync(
|
||||
ProductSearchCriteria criteria,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var query = _context.Products.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
|
||||
query = query.Where(p => EF.Functions.Like(p.Name, $"%{criteria.SearchTerm}%"));
|
||||
|
||||
if (criteria.CategoryId.HasValue)
|
||||
query = query.Where(p => p.CategoryId == criteria.CategoryId);
|
||||
|
||||
if (criteria.MinPrice.HasValue)
|
||||
query = query.Where(p => p.Price >= criteria.MinPrice);
|
||||
|
||||
if (criteria.MaxPrice.HasValue)
|
||||
query = query.Where(p => p.Price <= criteria.MaxPrice);
|
||||
|
||||
return await query
|
||||
.OrderBy(p => p.Name)
|
||||
.Skip((criteria.Page - 1) * criteria.PageSize)
|
||||
.Take(criteria.PageSize)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dapper for Performance
|
||||
|
||||
```csharp
|
||||
public class DapperProductRepository : IProductRepository
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt
|
||||
FROM Products
|
||||
WHERE Id = @Id AND IsDeleted = 0
|
||||
""";
|
||||
|
||||
return await _connection.QueryFirstOrDefaultAsync<Product>(
|
||||
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Product>> SearchAsync(
|
||||
ProductSearchCriteria criteria,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = new StringBuilder("""
|
||||
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt
|
||||
FROM Products
|
||||
WHERE IsDeleted = 0
|
||||
""");
|
||||
|
||||
var parameters = new DynamicParameters();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
|
||||
{
|
||||
sql.Append(" AND Name LIKE @SearchTerm");
|
||||
parameters.Add("SearchTerm", $"%{criteria.SearchTerm}%");
|
||||
}
|
||||
|
||||
if (criteria.CategoryId.HasValue)
|
||||
{
|
||||
sql.Append(" AND CategoryId = @CategoryId");
|
||||
parameters.Add("CategoryId", criteria.CategoryId);
|
||||
}
|
||||
|
||||
if (criteria.MinPrice.HasValue)
|
||||
{
|
||||
sql.Append(" AND Price >= @MinPrice");
|
||||
parameters.Add("MinPrice", criteria.MinPrice);
|
||||
}
|
||||
|
||||
if (criteria.MaxPrice.HasValue)
|
||||
{
|
||||
sql.Append(" AND Price <= @MaxPrice");
|
||||
parameters.Add("MaxPrice", criteria.MaxPrice);
|
||||
}
|
||||
|
||||
sql.Append(" ORDER BY Name OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY");
|
||||
parameters.Add("Offset", (criteria.Page - 1) * criteria.PageSize);
|
||||
parameters.Add("PageSize", criteria.PageSize);
|
||||
|
||||
var results = await _connection.QueryAsync<Product>(
|
||||
new CommandDefinition(sql.ToString(), parameters, cancellationToken: ct));
|
||||
|
||||
return results.ToList();
|
||||
}
|
||||
|
||||
// Multi-mapping for related data
|
||||
public async Task<Order?> GetOrderWithItemsAsync(int orderId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT o.*, oi.*, p.*
|
||||
FROM Orders o
|
||||
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
|
||||
LEFT JOIN Products p ON oi.ProductId = p.Id
|
||||
WHERE o.Id = @OrderId
|
||||
""";
|
||||
|
||||
var orderDictionary = new Dictionary<int, Order>();
|
||||
|
||||
await _connection.QueryAsync<Order, OrderItem, Product, Order>(
|
||||
new CommandDefinition(sql, new { OrderId = orderId }, cancellationToken: ct),
|
||||
(order, item, product) =>
|
||||
{
|
||||
if (!orderDictionary.TryGetValue(order.Id, out var existingOrder))
|
||||
{
|
||||
existingOrder = order;
|
||||
existingOrder.Items = new List<OrderItem>();
|
||||
orderDictionary.Add(order.Id, existingOrder);
|
||||
}
|
||||
|
||||
if (item != null)
|
||||
{
|
||||
item.Product = product;
|
||||
existingOrder.Items.Add(item);
|
||||
}
|
||||
|
||||
return existingOrder;
|
||||
},
|
||||
splitOn: "Id,Id");
|
||||
|
||||
return orderDictionary.Values.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Caching Patterns
|
||||
|
||||
### Multi-Level Cache with Redis
|
||||
|
||||
```csharp
|
||||
public class CachedProductService : IProductService
|
||||
{
|
||||
private readonly IProductRepository _repository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly IDistributedCache _distributedCache;
|
||||
private readonly ILogger<CachedProductService> _logger;
|
||||
|
||||
private static readonly TimeSpan MemoryCacheDuration = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan DistributedCacheDuration = TimeSpan.FromMinutes(15);
|
||||
|
||||
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = $"product:{id}";
|
||||
|
||||
// L1: Memory cache (in-process, fastest)
|
||||
if (_memoryCache.TryGetValue(cacheKey, out Product? cached))
|
||||
{
|
||||
_logger.LogDebug("L1 cache hit for {CacheKey}", cacheKey);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// L2: Distributed cache (Redis)
|
||||
var distributed = await _distributedCache.GetStringAsync(cacheKey, ct);
|
||||
if (distributed != null)
|
||||
{
|
||||
_logger.LogDebug("L2 cache hit for {CacheKey}", cacheKey);
|
||||
var product = JsonSerializer.Deserialize<Product>(distributed);
|
||||
|
||||
// Populate L1
|
||||
_memoryCache.Set(cacheKey, product, MemoryCacheDuration);
|
||||
return product;
|
||||
}
|
||||
|
||||
// L3: Database
|
||||
_logger.LogDebug("Cache miss for {CacheKey}, fetching from database", cacheKey);
|
||||
var fromDb = await _repository.GetByIdAsync(id, ct);
|
||||
|
||||
if (fromDb != null)
|
||||
{
|
||||
var serialized = JsonSerializer.Serialize(fromDb);
|
||||
|
||||
// Populate both caches
|
||||
await _distributedCache.SetStringAsync(
|
||||
cacheKey,
|
||||
serialized,
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = DistributedCacheDuration
|
||||
},
|
||||
ct);
|
||||
|
||||
_memoryCache.Set(cacheKey, fromDb, MemoryCacheDuration);
|
||||
}
|
||||
|
||||
return fromDb;
|
||||
}
|
||||
|
||||
public async Task InvalidateAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = $"product:{id}";
|
||||
|
||||
_memoryCache.Remove(cacheKey);
|
||||
await _distributedCache.RemoveAsync(cacheKey, ct);
|
||||
|
||||
_logger.LogInformation("Invalidated cache for {CacheKey}", cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Stale-while-revalidate pattern
|
||||
public class StaleWhileRevalidateCache<T>
|
||||
{
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly TimeSpan _freshDuration;
|
||||
private readonly TimeSpan _staleDuration;
|
||||
|
||||
public async Task<T?> GetOrCreateAsync(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cached = await _cache.GetStringAsync(key, ct);
|
||||
|
||||
if (cached != null)
|
||||
{
|
||||
var entry = JsonSerializer.Deserialize<CacheEntry<T>>(cached)!;
|
||||
|
||||
if (entry.IsStale && !entry.IsExpired)
|
||||
{
|
||||
// Return stale data immediately, refresh in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var fresh = await factory(CancellationToken.None);
|
||||
await SetAsync(key, fresh, CancellationToken.None);
|
||||
});
|
||||
}
|
||||
|
||||
if (!entry.IsExpired)
|
||||
return entry.Value;
|
||||
}
|
||||
|
||||
// Cache miss or expired
|
||||
var value = await factory(ct);
|
||||
await SetAsync(key, value, ct);
|
||||
return value;
|
||||
}
|
||||
|
||||
private record CacheEntry<TValue>(TValue Value, DateTime CreatedAt)
|
||||
{
|
||||
public bool IsStale => DateTime.UtcNow - CreatedAt > _freshDuration;
|
||||
public bool IsExpired => DateTime.UtcNow - CreatedAt > _staleDuration;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Unit Tests with xUnit and Moq
|
||||
|
||||
```csharp
|
||||
public class OrderServiceTests
|
||||
{
|
||||
private readonly Mock<IOrderRepository> _mockRepository;
|
||||
private readonly Mock<IStockService> _mockStockService;
|
||||
private readonly Mock<IValidator<CreateOrderRequest>> _mockValidator;
|
||||
private readonly OrderService _sut; // System Under Test
|
||||
|
||||
public OrderServiceTests()
|
||||
{
|
||||
_mockRepository = new Mock<IOrderRepository>();
|
||||
_mockStockService = new Mock<IStockService>();
|
||||
_mockValidator = new Mock<IValidator<CreateOrderRequest>>();
|
||||
|
||||
// Default: validation passes
|
||||
_mockValidator
|
||||
.Setup(v => v.ValidateAsync(It.IsAny<CreateOrderRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ValidationResult());
|
||||
|
||||
_sut = new OrderService(
|
||||
_mockRepository.Object,
|
||||
_mockStockService.Object,
|
||||
_mockValidator.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrderAsync_WithValidRequest_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateOrderRequest
|
||||
{
|
||||
ProductId = "PROD-001",
|
||||
Quantity = 5,
|
||||
CustomerOrderCode = "ORD-2024-001"
|
||||
};
|
||||
|
||||
_mockStockService
|
||||
.Setup(s => s.CheckAsync("PROD-001", 5, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new StockResult { IsAvailable = true, Available = 10 });
|
||||
|
||||
_mockRepository
|
||||
.Setup(r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Order { Id = 1, CustomerOrderCode = "ORD-2024-001" });
|
||||
|
||||
// Act
|
||||
var result = await _sut.CreateOrderAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Equal(1, result.Value.Id);
|
||||
|
||||
_mockRepository.Verify(
|
||||
r => r.CreateAsync(It.Is<Order>(o => o.CustomerOrderCode == "ORD-2024-001"),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrderAsync_WithInsufficientStock_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = 100 };
|
||||
|
||||
_mockStockService
|
||||
.Setup(s => s.CheckAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new StockResult { IsAvailable = false, Available = 5 });
|
||||
|
||||
// Act
|
||||
var result = await _sut.CreateOrderAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal("INSUFFICIENT_STOCK", result.ErrorCode);
|
||||
Assert.Contains("5 available", result.Error);
|
||||
|
||||
_mockRepository.Verify(
|
||||
r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(-100)]
|
||||
public async Task CreateOrderAsync_WithInvalidQuantity_ReturnsValidationError(int quantity)
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = quantity };
|
||||
|
||||
_mockValidator
|
||||
.Setup(v => v.ValidateAsync(request, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ValidationResult(new[]
|
||||
{
|
||||
new ValidationFailure("Quantity", "Quantity must be greater than 0")
|
||||
}));
|
||||
|
||||
// Act
|
||||
var result = await _sut.CreateOrderAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal("VALIDATION_ERROR", result.ErrorCode);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Tests with WebApplicationFactory
|
||||
|
||||
```csharp
|
||||
public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ProductsApiTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace real database with in-memory
|
||||
services.RemoveAll<DbContextOptions<AppDbContext>>();
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseInMemoryDatabase("TestDb"));
|
||||
|
||||
// Replace Redis with memory cache
|
||||
services.RemoveAll<IDistributedCache>();
|
||||
services.AddDistributedMemoryCache();
|
||||
});
|
||||
});
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProduct_WithValidId_ReturnsProduct()
|
||||
{
|
||||
// Arrange
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
context.Products.Add(new Product
|
||||
{
|
||||
Id = "TEST-001",
|
||||
Name = "Test Product",
|
||||
Price = 99.99m
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/products/TEST-001");
|
||||
|
||||
// Assert
|
||||
response.EnsureSuccessStatusCode();
|
||||
var product = await response.Content.ReadFromJsonAsync<Product>();
|
||||
Assert.Equal("Test Product", product!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProduct_WithInvalidId_Returns404()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/products/NONEXISTENT");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO
|
||||
|
||||
1. **Use async/await** all the way through the call stack
|
||||
2. **Inject dependencies** through constructor injection
|
||||
3. **Use IOptions<T>** for typed configuration
|
||||
4. **Return Result types** instead of throwing exceptions for business logic
|
||||
5. **Use CancellationToken** in all async methods
|
||||
6. **Prefer Dapper** for read-heavy, performance-critical queries
|
||||
7. **Use EF Core** for complex domain models with change tracking
|
||||
8. **Cache aggressively** with proper invalidation strategies
|
||||
9. **Write unit tests** for business logic, integration tests for APIs
|
||||
10. **Use record types** for DTOs and immutable data
|
||||
|
||||
### DON'T
|
||||
|
||||
1. **Don't block on async** with `.Result` or `.Wait()`
|
||||
2. **Don't use async void** except for event handlers
|
||||
3. **Don't catch generic Exception** without re-throwing or logging
|
||||
4. **Don't hardcode** configuration values
|
||||
5. **Don't expose EF entities** directly in APIs (use DTOs)
|
||||
6. **Don't forget** `AsNoTracking()` for read-only queries
|
||||
7. **Don't ignore** CancellationToken parameters
|
||||
8. **Don't create** `new HttpClient()` manually (use IHttpClientFactory)
|
||||
9. **Don't mix** sync and async code unnecessarily
|
||||
10. **Don't skip** validation at API boundaries
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **N+1 Queries**: Use `.Include()` or explicit joins
|
||||
- **Memory Leaks**: Dispose IDisposable resources, use `using`
|
||||
- **Deadlocks**: Don't mix sync and async, use ConfigureAwait(false) in libraries
|
||||
- **Over-fetching**: Select only needed columns, use projections
|
||||
- **Missing Indexes**: Check query plans, add indexes for common filters
|
||||
- **Timeout Issues**: Configure appropriate timeouts for HTTP clients
|
||||
- **Cache Stampede**: Use distributed locks for cache population
|
||||
|
||||
## Resources
|
||||
|
||||
- **assets/service-template.cs.template**: Complete service implementation template
|
||||
- **assets/repository-template.cs.template**: Repository pattern implementation
|
||||
- **references/ef-core-best-practices.md**: EF Core optimization guide
|
||||
- **references/dapper-patterns.md**: Advanced Dapper usage patterns
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: nexus-git-workflow
|
||||
description: Guidelines and standards for Git workflow, commits, and PRs in NexusReader
|
||||
---
|
||||
# NexusReader Git Workflow Standards
|
||||
|
||||
When working with Git and remote repositories for NexusReader, adhere to the following standards:
|
||||
|
||||
- **System Prompts in Tasks**: Tasks in the tracker *usually include* a system prompt that you should use directly for implementation.
|
||||
- **Pull Request Traceability**: When you create a pull request, it must include a reference to at least one task from the tracker (e.g., "Fixes #123" or "Resolves #456").
|
||||
- **Gitea MCP Server**: Use the **Gitea MCP server** whenever possible when exchanging data with a remote repository.
|
||||
- **Atomic Commits**: Create *atomic commits* that represent a single logical change. This makes reviewing, reverting, and bisecting easier.
|
||||
- **Addressing Comments**: When addressing comments on a pull request, always refer to specific comments and try to resolve them within the conversation.
|
||||
@@ -44,3 +44,6 @@ description: Design System & Component rules for Blazor
|
||||
- **Interactive Flow:**
|
||||
- AI Assistant interactions must be non-blocking and smoothly transition using CSS animations.
|
||||
- Interactive elements must have clear `:hover`, `:active`, and `:focus` states.
|
||||
- **Glass Panel Standard:** All primary data panels (`.glass-panel`) must implement the following interaction signature:
|
||||
- `transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)`
|
||||
- `:hover` state must include: `transform: translateY(-4px)`, increased background opacity, and a subtle `--nexus-neon` border highlight (e.g., `rgba(0, 255, 153, 0.2)`).
|
||||
+3
-1
@@ -29,4 +29,6 @@ Thumbs.db
|
||||
*.epub
|
||||
|
||||
.fake
|
||||
src/NexusReader.Web.New/nexus.db
|
||||
src/NexusReader.Web/nexus.db
|
||||
src/NexusReader.Web/wwwroot/covers/
|
||||
src/NexusReader.Web/wwwroot/uploads/
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||
<PackageVersion Include="Mapster" Version="10.0.7" />
|
||||
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
|
||||
<PackageVersion Include="MediatR" Version="12.1.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
|
||||
<PackageVersion Include="Pgvector" Version="0.3.0" />
|
||||
<PackageVersion Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Resilience" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
|
||||
<PackageVersion Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
|
||||
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.23" />
|
||||
<PackageVersion Include="Hangfire.PostgreSql" Version="1.21.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers.Data.Cl100kBase" Version="2.0.0" />
|
||||
<PackageVersion Include="Neo4j.Driver" Version="6.1.1" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageVersion Include="Polly" Version="8.6.6" />
|
||||
<PackageVersion Include="Polly.Extensions.Http" Version="3.0.0" />
|
||||
<PackageVersion Include="Qdrant.Client" Version="1.18.1" />
|
||||
<PackageVersion Include="Stripe.net" Version="51.1.0" />
|
||||
<PackageVersion Include="VersOne.Epub" Version="3.3.6" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="9.0.14" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.20" />
|
||||
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.20" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.0" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageVersion Include="Moq" Version="4.20.70" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+3
-3
@@ -3,20 +3,20 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy csproj files and restore dependencies
|
||||
COPY ["src/NexusReader.Web.New/NexusReader.Web.csproj", "src/NexusReader.Web.New/"]
|
||||
COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"]
|
||||
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
|
||||
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
|
||||
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
|
||||
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
|
||||
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
|
||||
|
||||
RUN dotnet restore "src/NexusReader.Web.New/NexusReader.Web.csproj"
|
||||
RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
|
||||
|
||||
# Copy the rest of the source code
|
||||
COPY . .
|
||||
|
||||
# Build and publish
|
||||
WORKDIR "/src/src/NexusReader.Web.New"
|
||||
WORKDIR "/src/src/NexusReader.Web"
|
||||
RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Stage 2: Runtime
|
||||
|
||||
+5
-2
@@ -9,7 +9,10 @@
|
||||
<Project Path="src/NexusReader.Data/NexusReader.Data.csproj" />
|
||||
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/NexusReader.Web.New/">
|
||||
<Project Path="src/NexusReader.Web.New/NexusReader.Web.csproj" />
|
||||
<Folder Name="/src/NexusReader.Web/">
|
||||
<Project Path="src/NexusReader.Web/NexusReader.Web.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 📖 Nexus Reader
|
||||
|
||||
Nexus Reader is a state-of-the-art, cross-platform Blazor .NET 10 immersive e-book reader, powered by **Native AOT**, **Clean Architecture**, **CQRS**, and interactive **D3.js Relationship Graphs** built on vector-based AI semantics.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features & Architecture Highlights
|
||||
|
||||
### 📁 Ingestion & Description persistence
|
||||
- Extracted and persistent **book descriptions** from EPUB package metadata during book ingestion.
|
||||
- The `Description` field propagates cleanly from the `Ebook` entity through Mapster to `LastReadBookDto` and `UserProfileDto`.
|
||||
|
||||
### 🔗 Deep-Link Routing
|
||||
- Implemented deep-link route activation: `/reader/{bookId}?chapter=N`.
|
||||
- Allows instant resume of reading session coordinates and loads the specific chapter chapter directly via URL query parameters.
|
||||
|
||||
### 🛡️ Downstream AI Resilience
|
||||
- Standard resilience engine in `DependencyInjection.cs` utilizing the **Polly** package (`ai-retry`).
|
||||
- Automatically intercepts, handles, and retries on both rate-limits (`429 Too Many Requests`) and downstream capacity overloads (`503 ServiceUnavailable` / `high demand`).
|
||||
|
||||
### ⚙️ Concurrent Request Deduplication
|
||||
- Multi-client InteractiveAuto Blazor circuit synchronization is backed by a thread-safe active task registry in `KnowledgeService` which ensures that identical concurrent requests await a single shared task instance, eliminating redundant LLM queries.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Build & Verification Gate
|
||||
|
||||
Ensure the dotnet workload matches the active SDK, and compile the full solution utilizing:
|
||||
|
||||
```bash
|
||||
dotnet build NexusReader.slnx --no-restore
|
||||
```
|
||||
|
||||
Run test suite:
|
||||
|
||||
```bash
|
||||
dotnet test --no-restore
|
||||
```
|
||||
@@ -26,12 +26,50 @@ services:
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- ConnectionStrings__PostgresConnection=Host=db;Database=nexus_db;Username=nexus_user;Password=nexus_password
|
||||
- ConnectionStrings__QdrantConnection=Host=qdrant;Port=6334
|
||||
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
||||
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
|
||||
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
|
||||
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
neo4j:
|
||||
condition: service_healthy
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: nexus-qdrant
|
||||
ports:
|
||||
- "6333:6333"
|
||||
- "6334:6334"
|
||||
volumes:
|
||||
- qdrant_data:/qdrant/storage
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
neo4j:
|
||||
image: neo4j:5-community
|
||||
container_name: nexus-neo4j
|
||||
ports:
|
||||
- "7474:7474"
|
||||
- "7687:7687"
|
||||
environment:
|
||||
- NEO4J_AUTH=none
|
||||
volumes:
|
||||
- neo4j_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "cypher-shell -u neo4j -p '' 'RETURN 1' || exit 0"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
qdrant_data:
|
||||
neo4j_data:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# -------------------------------------------------------------
|
||||
# Debug helper for NexusReader.Web.New (Blazor Server)
|
||||
# Debug helper for NexusReader.Web (Blazor Server)
|
||||
# -------------------------------------------------------------
|
||||
# 1️⃣ Ensure the port is free before starting the server.
|
||||
# 2️⃣ Starts the server project in the background.
|
||||
@@ -10,7 +10,7 @@
|
||||
# -------------------------------------------------------------
|
||||
|
||||
# ---- configuration ------------------------------------------------
|
||||
SERVER_PROJECT="src/NexusReader.Web.New/NexusReader.Web.csproj"
|
||||
SERVER_PROJECT="src/NexusReader.Web/NexusReader.Web.csproj"
|
||||
APP_URL="http://localhost:5104"
|
||||
DEBUG_PORT=9222
|
||||
TMP_PROFILE="/tmp/blazor-chrome-debug"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for broadcasting real-time sync events to connected clients.
|
||||
/// Defined in Application to prevent a direct dependency on SignalR in Application layer handlers.
|
||||
/// </summary>
|
||||
public interface ISyncBroadcaster
|
||||
{
|
||||
/// <summary>
|
||||
/// Broadcasts a reading progress update to all devices belonging to the specified user,
|
||||
/// optionally excluding the originating connection.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user whose other devices should be notified.</param>
|
||||
/// <param name="pageId">The block/page ID the user has reached.</param>
|
||||
/// <param name="timestamp">The server-side UTC timestamp of the update.</param>
|
||||
/// <param name="excludedConnectionId">SignalR connection ID to exclude (the sender's device).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task BroadcastProgressAsync(
|
||||
string userId,
|
||||
string pageId,
|
||||
DateTime timestamp,
|
||||
string? excludedConnectionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts real-time ingestion status updates to a specific user.
|
||||
/// This is used by background workers to provide feedback during AI-intensive processing.
|
||||
/// </summary>
|
||||
/// <param name="userId">The ID of the user who owns the ingestion request.</param>
|
||||
/// <param name="message">A human-readable status message (e.g., "Parsing chapters...").</param>
|
||||
/// <param name="progress">Progress percentage (0.0 to 1.0).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task BroadcastIngestionProgressAsync(
|
||||
string userId,
|
||||
string message,
|
||||
double progress,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for Ebook and Author persistence operations.
|
||||
/// Defined in the Application layer to avoid a direct dependency on EF Core.
|
||||
/// </summary>
|
||||
public interface IEbookRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds an author by name using a case-insensitive comparison.
|
||||
/// </summary>
|
||||
Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new author to the repository (staged, not yet persisted).
|
||||
/// </summary>
|
||||
void AddAuthor(Author author);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new ebook to the repository (staged, not yet persisted).
|
||||
/// </summary>
|
||||
void AddEbook(Ebook ebook);
|
||||
|
||||
/// <summary>
|
||||
/// Persists all staged changes to the underlying store.
|
||||
/// </summary>
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
using NexusReader.Domain.Entities;
|
||||
using FluentResults;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface IBillingService
|
||||
{
|
||||
Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId);
|
||||
Task<bool> HandleSubscriptionDeletedAsync(string customerEmail);
|
||||
Task<Result> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId);
|
||||
Task<Result> HandleSubscriptionDeletedAsync(string customerEmail);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing ebook and cover file storage.
|
||||
/// </summary>
|
||||
public interface IBookStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves an ebook file and returns its relative path/URL.
|
||||
/// </summary>
|
||||
Task<string> SaveEbookAsync(byte[] data, string fileName);
|
||||
|
||||
/// <summary>
|
||||
/// Saves an ebook file using a stream and returns its relative path/URL.
|
||||
/// </summary>
|
||||
Task<string> SaveEbookAsync(Stream data, string fileName);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a cover image and returns its relative path/URL.
|
||||
/// Returns null if no cover data is provided.
|
||||
/// </summary>
|
||||
Task<string?> SaveCoverAsync(byte[] data, string fileName);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a cover image using a stream and returns its relative path/URL.
|
||||
/// Returns null if no cover data is provided.
|
||||
/// </summary>
|
||||
Task<string?> SaveCoverAsync(Stream data, string fileName);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Queries.Reader;
|
||||
using System.IO;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface IEpubMetadataExtractor
|
||||
{
|
||||
Task<Result<LocalEpubMetadata>> ExtractMetadataAsync(Stream epubStream);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Queries.Reader;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and parses EPUB content for a specific ebook and chapter.
|
||||
/// </summary>
|
||||
public interface IEpubReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the content blocks for a given chapter of the specified ebook.
|
||||
/// </summary>
|
||||
/// <param name="ebookId">The unique ID of the ebook to read.</param>
|
||||
/// <param name="chapterIndex">Zero-based chapter index.</param>
|
||||
/// <param name="userId">The authenticated user's ID (used for tenant isolation in the DB lookup).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(
|
||||
Guid ebookId,
|
||||
int chapterIndex,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Queries.Reader;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface IEpubService
|
||||
{
|
||||
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface IIdentityService
|
||||
{
|
||||
event Func<Task>? OnStateInvalidated;
|
||||
Task<Result> RegisterAsync(string email, string password);
|
||||
Task<Result> LoginAsync(string email, string password, bool rememberMe = false);
|
||||
Task<Result> LogoutAsync();
|
||||
Task<Result<UserProfileDto>> GetProfileAsync();
|
||||
Task<Result> RefreshTokenAsync();
|
||||
}
|
||||
@@ -5,12 +5,14 @@ namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface IKnowledgeService
|
||||
{
|
||||
Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default);
|
||||
Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
||||
Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default);
|
||||
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface INativeStorageService
|
||||
{
|
||||
Result SaveString(string key, string value);
|
||||
Result<string?> GetString(string key);
|
||||
Result SaveBool(string key, bool value);
|
||||
Result<bool> GetBool(string key, bool defaultValue = false);
|
||||
Result Remove(string key);
|
||||
Task<Result> SaveStringAsync(string key, string value);
|
||||
Task<Result<string?>> GetStringAsync(string key);
|
||||
Task<Result> SaveBoolAsync(string key, bool value);
|
||||
Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false);
|
||||
Task<Result> RemoveAsync(string key);
|
||||
|
||||
Task<Result> SaveSecureString(string key, string value);
|
||||
Task<Result<string?>> GetSecureString(string key);
|
||||
Result RemoveSecure(string key);
|
||||
Task<Result> RemoveSecureAsync(string key);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Commands.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Command to ingest a new ebook into the library.
|
||||
/// </summary>
|
||||
/// <param name="Title">The title of the book.</param>
|
||||
/// <param name="AuthorName">The name of the author.</param>
|
||||
/// <param name="CoverImage">The raw bytes of the cover image (optional).</param>
|
||||
/// <param name="EpubData">The raw bytes of the EPUB file.</param>
|
||||
/// <param name="Description">The description or summary of the book (optional).</param>
|
||||
/// <param name="UserId">The ID of the user owning the book.</param>
|
||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation. Defaults to "global" for single-tenant or default usage.</param>
|
||||
public record IngestEbookCommand(
|
||||
string Title,
|
||||
string AuthorName,
|
||||
byte[]? CoverImage,
|
||||
byte[] EpubData,
|
||||
string? Description,
|
||||
string UserId,
|
||||
string TenantId = "global"
|
||||
) : ICommand<Guid>;
|
||||
@@ -0,0 +1,82 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Application.Commands.Library;
|
||||
|
||||
public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Result<Guid>>
|
||||
{
|
||||
private readonly IEbookRepository _ebookRepository;
|
||||
private readonly IBookStorageService _storageService;
|
||||
|
||||
public IngestEbookCommandHandler(
|
||||
IEbookRepository ebookRepository,
|
||||
IBookStorageService storageService)
|
||||
{
|
||||
_ebookRepository = ebookRepository;
|
||||
_storageService = storageService;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
string epubPath;
|
||||
string? coverUrl;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Save Files
|
||||
epubPath = await _storageService.SaveEbookAsync(request.EpubData, $"{request.Title}.epub");
|
||||
coverUrl = request.CoverImage != null && request.CoverImage.Length > 0
|
||||
? await _storageService.SaveCoverAsync(request.CoverImage, $"{request.Title}_cover.jpg")
|
||||
: null;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Storage I/O failure: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Storage failure: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 2. Resolve Author (case-insensitive via repository)
|
||||
var authorName = string.IsNullOrWhiteSpace(request.AuthorName)
|
||||
? "Unknown Author"
|
||||
: request.AuthorName.Trim();
|
||||
|
||||
var author = await _ebookRepository.FindAuthorByNameAsync(authorName, cancellationToken);
|
||||
if (author == null)
|
||||
{
|
||||
author = new Author { Name = authorName };
|
||||
_ebookRepository.AddAuthor(author);
|
||||
}
|
||||
|
||||
// 3. Create Ebook
|
||||
var ebook = new Ebook
|
||||
{
|
||||
Title = request.Title,
|
||||
Author = author,
|
||||
FilePath = epubPath,
|
||||
CoverUrl = coverUrl,
|
||||
Description = request.Description,
|
||||
UserId = request.UserId,
|
||||
TenantId = request.TenantId,
|
||||
AddedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_ebookRepository.AddEbook(ebook);
|
||||
await _ebookRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(ebook.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Database error during ingestion: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace NexusReader.Application.Commands.Library;
|
||||
|
||||
public record IngestEbookRequest(
|
||||
string Title,
|
||||
string AuthorName,
|
||||
string? CoverImageBase64,
|
||||
string EpubDataBase64,
|
||||
string? Description = null
|
||||
);
|
||||
@@ -3,4 +3,11 @@ using MediatR;
|
||||
|
||||
namespace NexusReader.Application.Commands.Sync;
|
||||
|
||||
public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest<Result>;
|
||||
public record UpdateReadingProgressCommand(
|
||||
string PageId,
|
||||
string UserId,
|
||||
Guid EbookId,
|
||||
double Progress,
|
||||
string? ChapterTitle,
|
||||
int ChapterIndex,
|
||||
string? ExcludedConnectionId = null) : IRequest<Result>;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Application.Commands.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="UpdateReadingProgressCommand"/>.
|
||||
/// Persists the user's reading position and broadcasts the update to other connected devices.
|
||||
/// </summary>
|
||||
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly ISyncBroadcaster _broadcaster;
|
||||
|
||||
public UpdateReadingProgressCommandHandler(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
ISyncBroadcaster broadcaster)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Fail("User not found.");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
user.LastReadPageId = request.PageId;
|
||||
user.LastReadAt = now;
|
||||
|
||||
var ebook = await context.Ebooks.FirstOrDefaultAsync(e => e.Id == request.EbookId, cancellationToken);
|
||||
if (ebook != null)
|
||||
{
|
||||
ebook.Progress = request.Progress;
|
||||
ebook.LastChapter = request.ChapterTitle;
|
||||
ebook.LastChapterIndex = request.ChapterIndex;
|
||||
ebook.LastReadDate = now;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Broadcast to other devices via the abstracted broadcaster
|
||||
await _broadcaster.BroadcastProgressAsync(
|
||||
request.UserId,
|
||||
request.PageId,
|
||||
now,
|
||||
request.ExcludedConnectionId,
|
||||
cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace NexusReader.Application.Constants;
|
||||
|
||||
public static class PlanConstants
|
||||
{
|
||||
public const string DefaultPlanName = "Free";
|
||||
public const int DefaultTokenLimit = 1000;
|
||||
public const string DefaultActivityLabel = "Brak aktywności";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace NexusReader.Application.Constants;
|
||||
|
||||
public static class StorageKeys
|
||||
{
|
||||
public const string AuthToken = "nexus_auth_token";
|
||||
public const string RefreshToken = "nexus_refresh_token";
|
||||
public const string UserEmail = "nexus_user_email";
|
||||
public const string UserTenant = "nexus_user_tenant";
|
||||
public const string UserRoles = "nexus_user_roles";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace NexusReader.Application.DTOs.AI;
|
||||
|
||||
public class GroundedResponseDto
|
||||
{
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
public List<CitationDto> Citations { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CitationDto
|
||||
{
|
||||
public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID
|
||||
public string Snippet { get; set; } = string.Empty; // Verified text snippet from context
|
||||
public string SourceBook { get; set; } = string.Empty; // Book title or description
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace NexusReader.Application.DTOs.User;
|
||||
|
||||
public record AuthorDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
using NexusReader.Application.Constants;
|
||||
|
||||
namespace NexusReader.Application.DTOs.User;
|
||||
|
||||
public record UserProfileDto
|
||||
{
|
||||
public string Email { get; init; } = string.Empty;
|
||||
public int AITokensUsed { get; init; }
|
||||
public Guid TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relational data for the current subscription plan.
|
||||
@@ -16,10 +19,23 @@ public record UserProfileDto
|
||||
/// Summary of the last read book.
|
||||
/// </summary>
|
||||
public LastReadBookDto? LastReadBook { get; init; }
|
||||
|
||||
public string[] Roles { get; init; } = Array.Empty<string>();
|
||||
|
||||
// Helper properties for UI compatibility
|
||||
public string CurrentPlan => Plan?.Name ?? PlanConstants.DefaultPlanName;
|
||||
public int AITokenLimit => Plan?.AITokenLimit ?? PlanConstants.DefaultTokenLimit;
|
||||
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
|
||||
}
|
||||
|
||||
public record LastReadBookDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public AuthorDto Author { get; init; } = new();
|
||||
public string? CoverUrl { get; init; }
|
||||
public double Progress { get; init; }
|
||||
public string? LastChapter { get; init; }
|
||||
public int LastChapterIndex { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Mapster;
|
||||
using MapsterMapper;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Reflection;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
|
||||
namespace NexusReader.Application.Mappings;
|
||||
|
||||
@@ -11,8 +12,9 @@ public static class MappingConfig
|
||||
{
|
||||
var config = TypeAdapterConfig.GlobalSettings;
|
||||
|
||||
// Manual registration for AOT (or use Source Generator)
|
||||
// config.NewConfig<Source, Destination>();
|
||||
config.NewConfig<NexusUser, UserProfileDto>();
|
||||
config.NewConfig<Ebook, LastReadBookDto>()
|
||||
.Map(dest => dest.Description, src => src.Description);
|
||||
|
||||
services.AddSingleton(config);
|
||||
services.AddScoped<IMapper, ServiceMapper>();
|
||||
@@ -20,3 +22,4 @@ public static class MappingConfig
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentResults" Version="4.0.0" />
|
||||
<PackageReference Include="Mapster" Version="10.0.7" />
|
||||
<PackageReference Include="Mapster.DependencyInjection" Version="10.0.7" />
|
||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="FluentResults" />
|
||||
<PackageReference Include="Mapster" />
|
||||
<PackageReference Include="Mapster.DependencyInjection" />
|
||||
<PackageReference Include="MediatR" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.Extensions.Resilience" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -4,5 +4,6 @@ namespace NexusReader.Application.Queries.Graph;
|
||||
|
||||
/// <param name="Text">Chapter or page content to extract the graph from.</param>
|
||||
/// <param name="TenantId">Tenant scope for knowledge extraction and caching.</param>
|
||||
public record GetKnowledgeGraphQuery(string Text, string TenantId) : IQuery<GraphDataDto>;
|
||||
/// <param name="EbookId">Optional Ebook ID to link the knowledge units to.</param>
|
||||
public record GetKnowledgeGraphQuery(string Text, string TenantId, Guid? EbookId = null) : IQuery<GraphDataDto>;
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@ internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledge
|
||||
if (string.IsNullOrWhiteSpace(request.Text))
|
||||
return Result.Ok(new GraphDataDto());
|
||||
|
||||
var result = await _knowledgeService.GetGraphDataAsync(request.Text, request.TenantId, cancellationToken);
|
||||
var result = await _knowledgeService.GetGraphDataAsync(
|
||||
request.Text,
|
||||
request.TenantId,
|
||||
ebookId: request.EbookId,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (result.IsFailed)
|
||||
return Result.Fail<GraphDataDto>(result.Errors);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
|
||||
namespace NexusReader.Application.Queries.Library;
|
||||
|
||||
public record AskLibraryQuestionQuery(string Question, string TenantId, Guid? EbookId = null, int Limit = 5)
|
||||
: IRequest<Result<GroundedResponseDto>>;
|
||||
|
||||
public class AskLibraryQuestionQueryHandler : IRequestHandler<AskLibraryQuestionQuery, Result<GroundedResponseDto>>
|
||||
{
|
||||
private readonly IKnowledgeService _knowledgeService;
|
||||
|
||||
public AskLibraryQuestionQueryHandler(IKnowledgeService knowledgeService)
|
||||
{
|
||||
_knowledgeService = knowledgeService;
|
||||
}
|
||||
|
||||
public async Task<Result<GroundedResponseDto>> Handle(AskLibraryQuestionQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Question))
|
||||
{
|
||||
return Result.Fail("Question cannot be empty.");
|
||||
}
|
||||
|
||||
return await _knowledgeService.AskQuestionAsync(
|
||||
request.Question,
|
||||
request.TenantId,
|
||||
request.EbookId,
|
||||
request.Limit,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Application.Queries.Library;
|
||||
|
||||
public record GetMyEbooksQuery(string UserId) : IRequest<Result<List<LastReadBookDto>>>;
|
||||
|
||||
public class GetMyEbooksQueryHandler : IRequestHandler<GetMyEbooksQuery, Result<List<LastReadBookDto>>>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public GetMyEbooksQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Result<List<LastReadBookDto>>> Handle(GetMyEbooksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var ebooks = await dbContext.Ebooks
|
||||
.Where(e => e.UserId == request.UserId)
|
||||
.OrderByDescending(e => e.LastReadDate ?? e.AddedDate)
|
||||
.Select(e => new LastReadBookDto
|
||||
{
|
||||
Id = e.Id,
|
||||
Title = e.Title,
|
||||
Author = new AuthorDto
|
||||
{
|
||||
Id = e.Author.Id,
|
||||
Name = e.Author.Name
|
||||
},
|
||||
CoverUrl = e.CoverUrl,
|
||||
Progress = e.Progress,
|
||||
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
|
||||
LastChapterIndex = e.LastChapterIndex,
|
||||
Description = e.Description
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(ebooks);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
using FluentResults;
|
||||
using Mapster;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
|
||||
using NexusReader.Data.Persistence;
|
||||
using Pgvector;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NexusReader.Application.Queries.Library;
|
||||
|
||||
public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId, int Limit = 5)
|
||||
@@ -17,15 +10,11 @@ public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId,
|
||||
|
||||
public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||
private readonly IKnowledgeService _knowledgeService;
|
||||
|
||||
public SearchLibrarySemanticallyQueryHandler(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator)
|
||||
public SearchLibrarySemanticallyQueryHandler(IKnowledgeService knowledgeService)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
_knowledgeService = knowledgeService;
|
||||
}
|
||||
|
||||
public async Task<Result<List<SemanticSearchResultDto>>> Handle(SearchLibrarySemanticallyQuery request, CancellationToken cancellationToken)
|
||||
@@ -35,84 +24,10 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
||||
return Result.Fail("Query text cannot be empty.");
|
||||
}
|
||||
|
||||
using var dbContext = _dbContextFactory.CreateDbContext();
|
||||
try
|
||||
{
|
||||
// 1. Generate embedding for user query
|
||||
var embeddingResponse = await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: cancellationToken);
|
||||
var queryVector = new Vector(embeddingResponse.First().Vector.ToArray());
|
||||
|
||||
// 2. Perform Cosine Similarity Search on Knowledge Units
|
||||
var candidates = await dbContext.KnowledgeUnits
|
||||
.AsNoTracking()
|
||||
.Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null)
|
||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||
.Take(request.Limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (!candidates.Any())
|
||||
{
|
||||
// Fallback to legacy cache if no granular units found
|
||||
var legacyResults = await dbContext.SemanticKnowledgeCache
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == request.TenantId && x.Vector != null)
|
||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||
.Take(request.Limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(legacyResults.Select(r => new SemanticSearchResultDto
|
||||
{
|
||||
ContentHash = r.ContentHash,
|
||||
Snippet = r.OriginalText,
|
||||
RelevanceScore = (float)(1 - r.Vector!.CosineDistance(queryVector))
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
// 3. Graph Expansion: Pull related units (e.g. Definitions, Next steps)
|
||||
var candidateIds = candidates.Select(c => c.Id).ToList();
|
||||
var links = await dbContext.KnowledgeUnitLinks
|
||||
.AsNoTracking()
|
||||
.Where(l => candidateIds.Contains(l.SourceUnitId) && (l.RelationType == "Defines" || l.RelationType == "Next"))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var relatedIds = links.Select(l => l.TargetUnitId).Distinct().ToList();
|
||||
var relatedUnits = await dbContext.KnowledgeUnits
|
||||
.AsNoTracking()
|
||||
.Where(u => relatedIds.Contains(u.Id))
|
||||
.ToDictionaryAsync(u => u.Id, cancellationToken);
|
||||
|
||||
// 4. Mapping with Context Enrichment
|
||||
var dtos = candidates.Select(c =>
|
||||
{
|
||||
var dto = new SemanticSearchResultDto
|
||||
{
|
||||
ContentHash = c.Id,
|
||||
Snippet = c.Content,
|
||||
UnitType = c.Type.ToString(),
|
||||
RelevanceScore = (float)(1 - c.Vector!.CosineDistance(queryVector)),
|
||||
Metadata = string.IsNullOrEmpty(c.MetadataJson)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<Dictionary<string, object>>(c.MetadataJson)
|
||||
};
|
||||
|
||||
// Enrich snippet with definitions if present
|
||||
var unitLinks = links.Where(l => l.SourceUnitId == c.Id && l.RelationType == "Defines").ToList();
|
||||
if (unitLinks.Any())
|
||||
{
|
||||
var definitions = unitLinks
|
||||
.Where(l => relatedUnits.ContainsKey(l.TargetUnitId))
|
||||
.Select(l => relatedUnits[l.TargetUnitId].Content);
|
||||
dto.Snippet = $"[Context: {string.Join("; ", definitions)}]\n{dto.Snippet}";
|
||||
}
|
||||
|
||||
return dto;
|
||||
}).ToList();
|
||||
|
||||
return Result.Ok(dtos);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Failed to perform semantic search").CausedBy(ex));
|
||||
}
|
||||
return await _knowledgeService.SearchLibrarySemanticallyAsync(
|
||||
request.QueryText,
|
||||
request.TenantId,
|
||||
request.Limit,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,13 @@ using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Queries.Reader;
|
||||
|
||||
public record GetReaderPageQuery(int ChapterIndex = 0) : IQuery<ReaderPageViewModel>;
|
||||
/// <summary>
|
||||
/// Query to retrieve a specific chapter of a user's ebook.
|
||||
/// </summary>
|
||||
/// <param name="EbookId">The ID of the ebook to read.</param>
|
||||
/// <param name="ChapterIndex">Zero-based chapter index.</param>
|
||||
/// <param name="UserId">The authenticated user's ID for tenant isolation.</param>
|
||||
public record GetReaderPageQuery(
|
||||
Guid EbookId,
|
||||
int ChapterIndex = 0,
|
||||
string? UserId = null) : IQuery<ReaderPageViewModel>;
|
||||
|
||||
@@ -6,15 +6,15 @@ namespace NexusReader.Application.Queries.Reader;
|
||||
|
||||
internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel>
|
||||
{
|
||||
private readonly IEpubService _epubService;
|
||||
private readonly IEpubReader _epubReader;
|
||||
|
||||
public GetReaderPageQueryHandler(IEpubService epubService)
|
||||
public GetReaderPageQueryHandler(IEpubReader epubReader)
|
||||
{
|
||||
_epubService = epubService;
|
||||
_epubReader = epubReader;
|
||||
}
|
||||
|
||||
public async Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
|
||||
public Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _epubService.GetEpubContentAsync(request.ChapterIndex);
|
||||
return _epubReader.GetEpubContentAsync(request.EbookId, request.ChapterIndex, request.UserId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace NexusReader.Application.Queries.Reader;
|
||||
|
||||
/// <summary>
|
||||
/// Represents metadata extracted from a local EPUB file.
|
||||
/// </summary>
|
||||
public record LocalEpubMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// The title of the book.
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The author(s) of the book.
|
||||
/// </summary>
|
||||
public string Author { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The raw bytes of the cover image, if available.
|
||||
/// </summary>
|
||||
public byte[]? CoverImage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The description or summary of the book.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
@@ -8,4 +8,4 @@ public abstract record ContentBlock(string Id);
|
||||
public record TextSegmentBlock(string Id, string Content) : ContentBlock(Id);
|
||||
public record AiActionTriggerBlock(string Id, string Dialogue, List<string> ActionOptions) : ContentBlock(Id);
|
||||
|
||||
public record ReaderPageViewModel(List<ContentBlock> Blocks, int CurrentChapterIndex, int TotalChapters, string ChapterTitle);
|
||||
public record ReaderPageViewModel(List<ContentBlock> Blocks, int CurrentChapterIndex, int TotalChapters, string ChapterTitle, Guid EbookId = default);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using MediatR;
|
||||
using FluentResults;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
|
||||
namespace NexusReader.Application.Queries.User;
|
||||
|
||||
public record GetUserProfileQuery(string UserId) : IRequest<Result<UserProfileDto>>;
|
||||
@@ -0,0 +1,67 @@
|
||||
using MediatR;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Application.Queries.User;
|
||||
|
||||
public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, Result<UserProfileDto>>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public GetUserProfileQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var profile = await dbContext.Users
|
||||
.Where(u => u.Id == request.UserId)
|
||||
.Select(u => new UserProfileDto
|
||||
{
|
||||
Email = u.Email ?? string.Empty,
|
||||
AITokensUsed = u.AITokensUsed,
|
||||
TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty,
|
||||
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
||||
{
|
||||
Id = u.SubscriptionPlan.Id,
|
||||
Name = u.SubscriptionPlan.PlanName,
|
||||
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
|
||||
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
|
||||
} : new SubscriptionPlanDto(),
|
||||
AverageQuizScore = u.QuizResults.Any(q => q.TotalQuestions > 0)
|
||||
? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100)
|
||||
: 0,
|
||||
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
|
||||
{
|
||||
Id = e.Id,
|
||||
Title = e.Title,
|
||||
Author = new AuthorDto
|
||||
{
|
||||
Id = e.Author.Id,
|
||||
Name = e.Author.Name
|
||||
},
|
||||
CoverUrl = e.CoverUrl,
|
||||
Progress = e.Progress,
|
||||
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
|
||||
LastChapterIndex = e.LastChapterIndex,
|
||||
Description = e.Description
|
||||
}).FirstOrDefault(),
|
||||
Roles = dbContext.UserRoles
|
||||
.Where(ur => ur.UserId == u.Id)
|
||||
.Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!)
|
||||
.ToArray()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (profile == null)
|
||||
{
|
||||
return Result.Fail("Profile not found.");
|
||||
}
|
||||
|
||||
return Result.Ok(profile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260510151022_NormalizeAuthor")]
|
||||
partial class NormalizeAuthor
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("AddedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("AuthorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("LastReadDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(768)");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("RelationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("SourceUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TargetUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceUnitId");
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AITokensUsed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastAiActionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastReadPageId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SubscriptionPlanId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionPlanId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CompletedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("TotalQuestions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
{
|
||||
b.Property<string>("ContentHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("JsonData")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ModelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("OriginalText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PromptVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(1536)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("PlanName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||
.WithMany("OutgoingLinks")
|
||||
.HasForeignKey("SourceUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
||||
.WithMany("IncomingLinks")
|
||||
.HasForeignKey("TargetUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceUnit");
|
||||
|
||||
b.Navigation("TargetUnit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriptionPlanId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SubscriptionPlan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("QuizResults")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
b.Navigation("OutgoingLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
|
||||
b.Navigation("QuizResults");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class NormalizeAuthor : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Author",
|
||||
table: "Ebooks");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AuthorId",
|
||||
table: "Ebooks",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Authors",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Authors", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Ebooks_AuthorId",
|
||||
table: "Ebooks",
|
||||
column: "AuthorId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Ebooks_Authors_AuthorId",
|
||||
table: "Ebooks",
|
||||
column: "AuthorId",
|
||||
principalTable: "Authors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Ebooks_Authors_AuthorId",
|
||||
table: "Ebooks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Authors");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Ebooks_AuthorId",
|
||||
table: "Ebooks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AuthorId",
|
||||
table: "Ebooks");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Author",
|
||||
table: "Ebooks",
|
||||
type: "character varying(255)",
|
||||
maxLength: 255,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
}
|
||||
}
|
||||
+697
@@ -0,0 +1,697 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260510161155_AddEbookProgressAndChapter")]
|
||||
partial class AddEbookProgressAndChapter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("AddedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("AuthorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LastChapter")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime?>("LastReadDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Progress")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(768)");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("RelationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("SourceUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TargetUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceUnitId");
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AITokensUsed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastAiActionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastReadPageId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SubscriptionPlanId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionPlanId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CompletedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("TotalQuestions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
{
|
||||
b.Property<string>("ContentHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("JsonData")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ModelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("OriginalText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PromptVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(1536)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("PlanName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||
.WithMany("OutgoingLinks")
|
||||
.HasForeignKey("SourceUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
||||
.WithMany("IncomingLinks")
|
||||
.HasForeignKey("TargetUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceUnit");
|
||||
|
||||
b.Navigation("TargetUnit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriptionPlanId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SubscriptionPlan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("QuizResults")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
b.Navigation("OutgoingLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
|
||||
b.Navigation("QuizResults");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEbookProgressAndChapter : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LastChapter",
|
||||
table: "Ebooks",
|
||||
type: "character varying(255)",
|
||||
maxLength: 255,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "Progress",
|
||||
table: "Ebooks",
|
||||
type: "double precision",
|
||||
nullable: false,
|
||||
defaultValue: 0.0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastChapter",
|
||||
table: "Ebooks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Progress",
|
||||
table: "Ebooks");
|
||||
}
|
||||
}
|
||||
}
|
||||
+700
@@ -0,0 +1,700 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260510171941_AddEbookLastChapterIndex")]
|
||||
partial class AddEbookLastChapterIndex
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("AddedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("AuthorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LastChapter")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("LastChapterIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("LastReadDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Progress")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(768)");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("RelationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("SourceUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TargetUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceUnitId");
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AITokensUsed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastAiActionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastReadPageId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SubscriptionPlanId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionPlanId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CompletedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("TotalQuestions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
{
|
||||
b.Property<string>("ContentHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("JsonData")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ModelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("OriginalText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PromptVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(1536)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("PlanName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||
.WithMany("OutgoingLinks")
|
||||
.HasForeignKey("SourceUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
||||
.WithMany("IncomingLinks")
|
||||
.HasForeignKey("TargetUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceUnit");
|
||||
|
||||
b.Navigation("TargetUnit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriptionPlanId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SubscriptionPlan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("QuizResults")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
b.Navigation("OutgoingLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
|
||||
b.Navigation("QuizResults");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEbookLastChapterIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "LastChapterIndex",
|
||||
table: "Ebooks",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastChapterIndex",
|
||||
table: "Ebooks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,24 @@ namespace NexusReader.Data.Migrations
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -165,21 +183,35 @@ namespace NexusReader.Data.Migrations
|
||||
b.Property<DateTime>("AddedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Author")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
b.Property<int>("AuthorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsReadyForReading")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastChapter")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("LastChapterIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("LastReadDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Progress")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
@@ -196,11 +228,13 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks", (string)null);
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
@@ -216,14 +250,12 @@ namespace NexusReader.Data.Migrations
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("EbookId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
@@ -242,11 +274,11 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceId");
|
||||
b.HasIndex("EbookId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits", (string)null);
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
@@ -278,7 +310,7 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks", (string)null);
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
@@ -413,7 +445,7 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults", (string)null);
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
@@ -458,7 +490,7 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache", (string)null);
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
@@ -493,7 +525,7 @@ namespace NexusReader.Data.Migrations
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans", (string)null);
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
@@ -587,15 +619,33 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
|
||||
.WithMany()
|
||||
.HasForeignKey("EbookId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Ebook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||
@@ -637,6 +687,11 @@ namespace NexusReader.Data.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
@@ -7,18 +7,18 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
|
||||
namespace NexusReader.Data.Persistence;
|
||||
|
||||
public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
@@ -24,13 +23,12 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
public DbSet<Ebook> Ebooks => Set<Ebook>();
|
||||
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
||||
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
||||
public DbSet<Author> Authors => Set<Author>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
|
||||
modelBuilder.Entity<NexusUser>(entity =>
|
||||
{
|
||||
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
|
||||
@@ -57,15 +55,19 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
entity.HasKey(e => e.ContentHash);
|
||||
entity.HasIndex(e => e.ContentHash).IsUnique();
|
||||
entity.HasIndex(e => e.TenantId);
|
||||
entity.Property(e => e.Vector).HasColumnType("vector(1536)");
|
||||
entity.Ignore(e => e.Embedding);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KnowledgeUnit>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.TenantId);
|
||||
entity.HasIndex(e => e.SourceId);
|
||||
entity.Property(e => e.Vector).HasColumnType("vector(768)");
|
||||
entity.HasIndex(e => e.EbookId);
|
||||
|
||||
entity.HasOne(e => e.Ebook)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.EbookId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KnowledgeUnitLink>(entity =>
|
||||
@@ -89,6 +91,11 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Author)
|
||||
.WithMany(a => a.Ebooks)
|
||||
.HasForeignKey(e => e.AuthorId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
entity.HasIndex(e => e.TenantId);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
|
||||
namespace NexusReader.Data.Persistence;
|
||||
|
||||
@@ -19,7 +18,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||
}
|
||||
|
||||
var basePath = currentDir != null
|
||||
? Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New")
|
||||
? Path.Combine(currentDir.FullName, "src", "NexusReader.Web")
|
||||
: Directory.GetCurrentDirectory();
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
@@ -38,7 +37,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||
connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres";
|
||||
}
|
||||
|
||||
optionsBuilder.UseNpgsql(connectionString, x => x.UseVector());
|
||||
optionsBuilder.UseNpgsql(connectionString);
|
||||
|
||||
return new AppDbContext(optionsBuilder.Options);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,33 @@ public static class DbInitializer
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
Console.WriteLine($"[Seeder] Admin user created successfully: {adminEmail}");
|
||||
|
||||
// Seed Sample Author
|
||||
var author = await dbContext.Authors.FirstOrDefaultAsync(a => a.Name == "Giorgio Vasari");
|
||||
if (author == null)
|
||||
{
|
||||
author = new Author { Name = "Giorgio Vasari" };
|
||||
dbContext.Authors.Add(author);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Seed Sample Ebook
|
||||
if (!dbContext.Ebooks.Any(e => e.UserId == adminUser.Id))
|
||||
{
|
||||
dbContext.Ebooks.Add(new Ebook
|
||||
{
|
||||
Title = "Lives of the Most Excellent Painters, Sculptors, and Architects",
|
||||
AuthorId = author.Id,
|
||||
UserId = adminUser.Id,
|
||||
FilePath = "wwwroot/assets/book.epub",
|
||||
AddedDate = DateTime.UtcNow,
|
||||
LastReadDate = DateTime.UtcNow,
|
||||
Progress = 0,
|
||||
LastChapter = "Introduction"
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
Console.WriteLine("[Seeder] Sample book seeded for admin.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
+703
@@ -0,0 +1,703 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260513181743_AddEbookReadyFlag")]
|
||||
partial class AddEbookReadyFlag
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("AddedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("AuthorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsReadyForReading")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastChapter")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("LastChapterIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("LastReadDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Progress")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(768)");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("RelationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("SourceUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TargetUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceUnitId");
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AITokensUsed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastAiActionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastReadPageId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SubscriptionPlanId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionPlanId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CompletedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("TotalQuestions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
{
|
||||
b.Property<string>("ContentHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("JsonData")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ModelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("OriginalText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PromptVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(1536)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("PlanName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||
.WithMany("OutgoingLinks")
|
||||
.HasForeignKey("SourceUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
||||
.WithMany("IncomingLinks")
|
||||
.HasForeignKey("TargetUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceUnit");
|
||||
|
||||
b.Navigation("TargetUnit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriptionPlanId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SubscriptionPlan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("QuizResults")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
b.Navigation("OutgoingLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
|
||||
b.Navigation("QuizResults");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEbookReadyFlag : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsReadyForReading",
|
||||
table: "Ebooks",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsReadyForReading",
|
||||
table: "Ebooks");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+712
@@ -0,0 +1,712 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260513183726_FixKnowledgeUnitEbookId")]
|
||||
partial class FixKnowledgeUnitEbookId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("AddedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("AuthorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsReadyForReading")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastChapter")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("LastChapterIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("LastReadDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Progress")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("EbookId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
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("EbookId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("RelationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("SourceUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TargetUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceUnitId");
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AITokensUsed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastAiActionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastReadPageId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SubscriptionPlanId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionPlanId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CompletedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("TotalQuestions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
{
|
||||
b.Property<string>("ContentHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("JsonData")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ModelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("OriginalText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PromptVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(1536)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("PlanName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
|
||||
.WithMany()
|
||||
.HasForeignKey("EbookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Ebook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||
.WithMany("OutgoingLinks")
|
||||
.HasForeignKey("SourceUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
||||
.WithMany("IncomingLinks")
|
||||
.HasForeignKey("TargetUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceUnit");
|
||||
|
||||
b.Navigation("TargetUnit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriptionPlanId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SubscriptionPlan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("QuizResults")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
b.Navigation("OutgoingLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
|
||||
b.Navigation("QuizResults");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FixKnowledgeUnitEbookId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_KnowledgeUnits_SourceId",
|
||||
table: "KnowledgeUnits");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceId",
|
||||
table: "KnowledgeUnits");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "EbookId",
|
||||
table: "KnowledgeUnits",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KnowledgeUnits_EbookId",
|
||||
table: "KnowledgeUnits",
|
||||
column: "EbookId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_KnowledgeUnits_Ebooks_EbookId",
|
||||
table: "KnowledgeUnits",
|
||||
column: "EbookId",
|
||||
principalTable: "Ebooks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_KnowledgeUnits_Ebooks_EbookId",
|
||||
table: "KnowledgeUnits");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_KnowledgeUnits_EbookId",
|
||||
table: "KnowledgeUnits");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EbookId",
|
||||
table: "KnowledgeUnits");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SourceId",
|
||||
table: "KnowledgeUnits",
|
||||
type: "character varying(128)",
|
||||
maxLength: 128,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KnowledgeUnits_SourceId",
|
||||
table: "KnowledgeUnits",
|
||||
column: "SourceId");
|
||||
}
|
||||
}
|
||||
}
|
||||
+711
@@ -0,0 +1,711 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260513185108_MakeKnowledgeUnitEbookIdNullable")]
|
||||
partial class MakeKnowledgeUnitEbookIdNullable
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("AddedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("AuthorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsReadyForReading")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastChapter")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("LastChapterIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("LastReadDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Progress")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("EbookId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
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("EbookId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("RelationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("SourceUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TargetUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceUnitId");
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AITokensUsed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastAiActionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastReadPageId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SubscriptionPlanId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionPlanId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CompletedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("TotalQuestions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
{
|
||||
b.Property<string>("ContentHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("JsonData")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ModelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("OriginalText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PromptVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(1536)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("PlanName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
|
||||
.WithMany()
|
||||
.HasForeignKey("EbookId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Ebook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||
.WithMany("OutgoingLinks")
|
||||
.HasForeignKey("SourceUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
||||
.WithMany("IncomingLinks")
|
||||
.HasForeignKey("TargetUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceUnit");
|
||||
|
||||
b.Navigation("TargetUnit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriptionPlanId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SubscriptionPlan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("QuizResults")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
b.Navigation("OutgoingLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
|
||||
b.Navigation("QuizResults");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeKnowledgeUnitEbookIdNullable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "EbookId",
|
||||
table: "KnowledgeUnits",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "EbookId",
|
||||
table: "KnowledgeUnits",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+714
@@ -0,0 +1,714 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260514184243_AddDescriptionToEbook")]
|
||||
partial class AddDescriptionToEbook
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("AddedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("AuthorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsReadyForReading")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastChapter")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("LastChapterIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("LastReadDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Progress")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("EbookId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
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("EbookId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("RelationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("SourceUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TargetUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceUnitId");
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AITokensUsed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastAiActionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastReadPageId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SubscriptionPlanId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionPlanId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CompletedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("TotalQuestions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
{
|
||||
b.Property<string>("ContentHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("JsonData")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ModelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("OriginalText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PromptVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(1536)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("PlanName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
|
||||
.WithMany()
|
||||
.HasForeignKey("EbookId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Ebook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||
.WithMany("OutgoingLinks")
|
||||
.HasForeignKey("SourceUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
||||
.WithMany("IncomingLinks")
|
||||
.HasForeignKey("TargetUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceUnit");
|
||||
|
||||
b.Navigation("TargetUnit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriptionPlanId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SubscriptionPlan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("QuizResults")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
b.Navigation("OutgoingLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
|
||||
b.Navigation("QuizResults");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDescriptionToEbook : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Description",
|
||||
table: "Ebooks",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Description",
|
||||
table: "Ebooks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace NexusReader.Domain.Entities;
|
||||
|
||||
public class Author
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public virtual ICollection<Ebook> Ebooks { get; set; } = new List<Ebook>();
|
||||
}
|
||||
@@ -15,8 +15,11 @@ public class Ebook
|
||||
[MaxLength(255)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(255)]
|
||||
public string Author { get; set; } = "Unknown";
|
||||
[Required]
|
||||
public int AuthorId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(AuthorId))]
|
||||
public virtual Author Author { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
@@ -31,6 +34,21 @@ public class Ebook
|
||||
|
||||
public DateTime? LastReadDate { get; set; }
|
||||
|
||||
public double Progress { get; set; } = 0;
|
||||
|
||||
[MaxLength(255)]
|
||||
public string? LastChapter { get; set; }
|
||||
|
||||
public int LastChapterIndex { get; set; } = 0;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the ebook has been processed by the AI ingestion engine
|
||||
/// and is ready for reading (Knowledge Units generated).
|
||||
/// </summary>
|
||||
public bool IsReadyForReading { get; set; } = false;
|
||||
|
||||
// Relationship to NexusUser
|
||||
[Required]
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using NexusReader.Domain.Enums;
|
||||
using Pgvector;
|
||||
|
||||
namespace NexusReader.Domain.Entities;
|
||||
|
||||
@@ -11,9 +10,10 @@ public class KnowledgeUnit
|
||||
[MaxLength(128)]
|
||||
public string Id { get; set; } = string.Empty; // Hash(Source + Content + Version)
|
||||
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string SourceId { get; set; } = string.Empty;
|
||||
public Guid? EbookId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(EbookId))]
|
||||
public virtual Ebook? Ebook { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
@@ -31,8 +31,6 @@ public class KnowledgeUnit
|
||||
[MaxLength(128)]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
public Vector? Vector { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Relationships
|
||||
|
||||
@@ -28,7 +28,8 @@ public class SemanticKnowledgeCache
|
||||
[MaxLength(128)]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
public Vector? Vector { get; set; }
|
||||
// Vector embedding for semantic search (768 dimensions)
|
||||
public Vector? Embedding { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
|
||||
<PackageReference Include="Pgvector" Version="0.3.2" />
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Pgvector" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Stores" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||
<UseMaui>true</UseMaui>
|
||||
<UseMauiEssentials>true</UseMauiEssentials>
|
||||
<SkipValidateMauiImplicitPackageReferences>true</SkipValidateMauiImplicitPackageReferences>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Maui.Essentials" Version="10.0.20" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using FluentResults;
|
||||
using Result = FluentResults.Result;
|
||||
using Microsoft.Maui.Devices;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using FluentResults;
|
||||
using Result = FluentResults.Result;
|
||||
using Microsoft.Maui.Storage;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
@@ -6,66 +7,66 @@ namespace NexusReader.Infrastructure.Mobile.Services;
|
||||
|
||||
public sealed class MauiStorageService : INativeStorageService
|
||||
{
|
||||
public Result SaveString(string key, string value)
|
||||
public Task<Result> SaveStringAsync(string key, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Preferences.Default.Set(key, value);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
return Task.FromResult(Result.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public Result<string?> GetString(string key)
|
||||
public Task<Result<string?>> GetStringAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Result.Ok(Preferences.Default.Get(key, (string?)null));
|
||||
return Task.FromResult(Result.Ok(Preferences.Default.Get(key, (string?)null)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
return Task.FromResult(Result.Fail<string?>(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public Result SaveBool(string key, bool value)
|
||||
public Task<Result> SaveBoolAsync(string key, bool value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Preferences.Default.Set(key, value);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
return Task.FromResult(Result.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public Result<bool> GetBool(string key, bool defaultValue = false)
|
||||
public Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Result.Ok(Preferences.Default.Get(key, defaultValue));
|
||||
return Task.FromResult(Result.Ok(Preferences.Default.Get(key, defaultValue)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
return Task.FromResult(Result.Fail<bool>(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public Result Remove(string key)
|
||||
public Task<Result> RemoveAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
Preferences.Default.Remove(key);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
return Task.FromResult(Result.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,16 +95,16 @@ public sealed class MauiStorageService : INativeStorageService
|
||||
}
|
||||
}
|
||||
|
||||
public Result RemoveSecure(string key)
|
||||
public Task<Result> RemoveSecureAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureStorage.Default.Remove(key);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
return Task.FromResult(Result.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ public class AiSettings
|
||||
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
public string Model { get; set; } = "gemini-1.5-flash";
|
||||
public string EmbeddingModel { get; set; } = "text-embedding-004";
|
||||
public string EmbeddingModel { get; set; } = "gemini-embedding-001";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of tokens allowed for input.
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using GeminiDotnet;
|
||||
using GeminiDotnet.Extensions.AI;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Infrastructure.RealTime;
|
||||
using NexusReader.Infrastructure.Services;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
using Polly;
|
||||
@@ -16,6 +19,10 @@ using NexusReader.Domain.Entities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using NexusReader.Application.Security.Authorization;
|
||||
using Qdrant.Client;
|
||||
using Neo4j.Driver;
|
||||
using Hangfire;
|
||||
using Hangfire.PostgreSql;
|
||||
|
||||
namespace NexusReader.Infrastructure;
|
||||
|
||||
@@ -27,21 +34,45 @@ public static class DependencyInjection
|
||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||
{
|
||||
services.AddDbContextFactory<AppDbContext>(options =>
|
||||
options.UseNpgsql(pgConnectionString, x => x.UseVector()));
|
||||
options.UseNpgsql(pgConnectionString),
|
||||
ServiceLifetime.Scoped);
|
||||
|
||||
// Also register a scoped DbContext for repositories that need it
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(pgConnectionString));
|
||||
}
|
||||
else
|
||||
{
|
||||
var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db";
|
||||
services.AddDbContextFactory<AppDbContext>(options =>
|
||||
options.UseSqlite(sqliteConnectionString),
|
||||
ServiceLifetime.Scoped);
|
||||
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlite(sqliteConnectionString));
|
||||
}
|
||||
|
||||
// Qdrant Client registration
|
||||
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
||||
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl)));
|
||||
|
||||
// Neo4j Driver registration
|
||||
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
||||
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, AuthTokens.None));
|
||||
|
||||
// Hangfire registration
|
||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||
{
|
||||
services.AddHangfire(config => config
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UsePostgreSqlStorage(options => options.UseNpgsqlConnection(pgConnectionString)));
|
||||
services.AddHangfireServer();
|
||||
}
|
||||
|
||||
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||
|
||||
Console.WriteLine($"[Infrastructure] AI Configured: Model={aiSettings.Model}, KeyPresent={!string.IsNullOrWhiteSpace(aiSettings.ApiKey) && aiSettings.ApiKey != "PLACEHOLDER"}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
||||
{
|
||||
Console.WriteLine("[Infrastructure] WARNING: AI API Key is missing or placeholder!");
|
||||
@@ -52,7 +83,12 @@ public static class DependencyInjection
|
||||
builder.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
|
||||
ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests") || ex.Message.Contains("quota")),
|
||||
ex.Message.Contains("429") ||
|
||||
ex.Message.Contains("Too Many Requests") ||
|
||||
ex.Message.Contains("quota") ||
|
||||
ex.Message.Contains("503") ||
|
||||
ex.Message.Contains("ServiceUnavailable") ||
|
||||
ex.Message.Contains("demand")),
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
MaxRetryAttempts = aiSettings.RetryAttempts,
|
||||
@@ -69,11 +105,23 @@ public static class DependencyInjection
|
||||
services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions
|
||||
{
|
||||
ApiKey = aiSettings.ApiKey,
|
||||
ModelId = aiSettings.EmbeddingModel ?? "text-embedding-004"
|
||||
ModelId = aiSettings.EmbeddingModel ?? "gemini-embedding-001"
|
||||
}));
|
||||
|
||||
// Application-layer service implementations
|
||||
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
||||
services.AddTransient<IEpubService, EpubService>();
|
||||
services.AddTransient<IEpubReader, EpubReaderService>();
|
||||
services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>();
|
||||
|
||||
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
|
||||
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
|
||||
services.AddScoped<IBookStorageService, BookStorageService>();
|
||||
|
||||
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||
|
||||
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
||||
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
||||
|
||||
services.AddAuthorizationCore(options =>
|
||||
{
|
||||
@@ -81,7 +129,6 @@ public static class DependencyInjection
|
||||
});
|
||||
|
||||
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
|
||||
|
||||
services.AddScoped<IInfrastructureMarker, InfrastructureMarker>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Commands.Sync;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.RealTime;
|
||||
|
||||
namespace NexusReader.Infrastructure.Handlers;
|
||||
|
||||
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly IHubContext<SyncHub> _hubContext;
|
||||
|
||||
public UpdateReadingProgressCommandHandler(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
IHubContext<SyncHub> hubContext)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Fail("User not found.");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
user.LastReadPageId = request.PageId;
|
||||
user.LastReadAt = now;
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Broadcast to other devices
|
||||
await _hubContext.Clients
|
||||
.Group($"User_{request.UserId}")
|
||||
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
@@ -10,24 +10,27 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PackageReference Include="GeminiDotnet.Extensions.AI" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" />
|
||||
<PackageReference Include="Hangfire.PostgreSql" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Resilience" Version="10.5.0" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers.Data.Cl100kBase" Version="2.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="Polly" Version="8.6.6" />
|
||||
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
||||
<PackageReference Include="Stripe.net" Version="51.1.0" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.3.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.Resilience" />
|
||||
<PackageReference Include="Microsoft.Bcl.Memory" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers.Data.Cl100kBase" />
|
||||
<PackageReference Include="Neo4j.Driver" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Polly" />
|
||||
<PackageReference Include="Polly.Extensions.Http" />
|
||||
<PackageReference Include="Qdrant.Client" />
|
||||
<PackageReference Include="Stripe.net" />
|
||||
<PackageReference Include="VersOne.Epub" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IEbookRepository"/>.
|
||||
/// Uses a scoped <see cref="AppDbContext"/> created via the factory for long-running operations.
|
||||
/// </summary>
|
||||
internal sealed class EbookRepository : IEbookRepository
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public EbookRepository(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Use PostgreSQL ILike for case-insensitive searching if on Npgsql provider,
|
||||
// otherwise fallback to string comparison.
|
||||
if (_context.Database.IsNpgsql())
|
||||
{
|
||||
return await _context.Authors
|
||||
.FirstOrDefaultAsync(a => EF.Functions.ILike(a.Name, name), cancellationToken);
|
||||
}
|
||||
|
||||
return await _context.Authors
|
||||
.FirstOrDefaultAsync(
|
||||
a => a.Name.ToLower() == name.ToLower(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddAuthor(Author author) => _context.Authors.Add(author);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddEbook(Ebook ebook)
|
||||
{
|
||||
// Explicitly set the readiness flag to false upon addition
|
||||
ebook.IsReadyForReading = false;
|
||||
_context.Ebooks.Add(ebook);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Infrastructure.RealTime;
|
||||
|
||||
namespace NexusReader.Infrastructure.RealTime;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR implementation of <see cref="ISyncBroadcaster"/>.
|
||||
/// Uses <see cref="IHubContext{SyncHub}"/> to push progress updates to all of a user's connected devices.
|
||||
/// </summary>
|
||||
internal sealed class SignalRSyncBroadcaster : ISyncBroadcaster
|
||||
{
|
||||
private readonly IHubContext<SyncHub> _hubContext;
|
||||
|
||||
public SignalRSyncBroadcaster(IHubContext<SyncHub> hubContext)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task BroadcastProgressAsync(
|
||||
string userId,
|
||||
string pageId,
|
||||
DateTime timestamp,
|
||||
string? excludedConnectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Using Clients.User(userId) targeted broadcasting.
|
||||
// This pushes to all of a user's connected devices across all sessions.
|
||||
if (!string.IsNullOrEmpty(excludedConnectionId))
|
||||
{
|
||||
await _hubContext.Clients
|
||||
.User(userId)
|
||||
.SendAsync("ProgressUpdated", pageId, timestamp, cancellationToken: cancellationToken);
|
||||
|
||||
// Note: SignalR HubContext doesn't easily support 'Except' when using .User(id)
|
||||
// from outside the Hub itself without custom IUserIdProvider.
|
||||
// If strict exclusion is needed, we'd use groups, but requirements mandate .User(userId).
|
||||
}
|
||||
else
|
||||
{
|
||||
await _hubContext.Clients
|
||||
.User(userId)
|
||||
.SendAsync("ProgressUpdated", pageId, timestamp, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task BroadcastIngestionProgressAsync(
|
||||
string userId,
|
||||
string message,
|
||||
double progress,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Pushes ingestion status (e.g., "Parsing chapters...") and progress (0.0-1.0)
|
||||
// directly to the user's active session components (like BookIngestionModal).
|
||||
await _hubContext.Clients
|
||||
.User(userId)
|
||||
.SendAsync("IngestionProgress", message, progress, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using NexusReader.Application.Commands.Sync;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace NexusReader.Infrastructure.RealTime;
|
||||
|
||||
@@ -15,12 +16,12 @@ public class SyncHub : Hub
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public async Task UpdateProgress(string pageId)
|
||||
public async Task UpdateProgress(string pageId, Guid ebookId, double progress, string? chapterTitle, int chapterIndex)
|
||||
{
|
||||
var userId = Context.UserIdentifier;
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
var userId = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId != null)
|
||||
{
|
||||
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId));
|
||||
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, ebookId, progress, chapterTitle, chapterIndex, Context.ConnectionId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
using NexusReader.Data.Persistence;
|
||||
using FluentResults;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
@@ -28,77 +29,93 @@ public class BillingService : IBillingService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
|
||||
public async Task<Result> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||
if (user == null)
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Attempted to update subscription for non-existent user: {Email}", customerEmail);
|
||||
return false;
|
||||
}
|
||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to update subscription for non-existent user: {Email}", customerEmail);
|
||||
return Result.Fail($"User {customerEmail} not found.");
|
||||
}
|
||||
|
||||
string targetPlanName = SubscriptionPlan.FreeName;
|
||||
int tokenLimit = 1000;
|
||||
string targetPlanName = SubscriptionPlan.FreeName;
|
||||
int tokenLimit = 1000;
|
||||
|
||||
if (stripeProductId == _stripeSettings.ProProductId)
|
||||
{
|
||||
targetPlanName = SubscriptionPlan.ProName;
|
||||
tokenLimit = 50000;
|
||||
}
|
||||
else if (stripeProductId == _stripeSettings.BasicProductId)
|
||||
{
|
||||
targetPlanName = SubscriptionPlan.BasicName;
|
||||
tokenLimit = 10000;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(stripeProductId) && stripeProductId != _stripeSettings.FreeProductId)
|
||||
{
|
||||
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
|
||||
}
|
||||
if (stripeProductId == _stripeSettings.ProProductId)
|
||||
{
|
||||
targetPlanName = SubscriptionPlan.ProName;
|
||||
tokenLimit = 50000;
|
||||
}
|
||||
else if (stripeProductId == _stripeSettings.BasicProductId)
|
||||
{
|
||||
targetPlanName = SubscriptionPlan.BasicName;
|
||||
tokenLimit = 10000;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(stripeProductId) && stripeProductId != _stripeSettings.FreeProductId)
|
||||
{
|
||||
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
|
||||
}
|
||||
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
|
||||
if (plan != null)
|
||||
{
|
||||
user.SubscriptionPlanId = plan.Id;
|
||||
user.AITokenLimit = tokenLimit;
|
||||
}
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
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);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
_logger.LogError("Failed to update user {Email} after subscription change: {Errors}",
|
||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
return false;
|
||||
}
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
_logger.LogError("Failed to update user {Email} after subscription change: {Errors}",
|
||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
return Result.Fail(result.Errors.Select(e => e.Description).FirstOrDefault() ?? "Failed to update user profile.");
|
||||
}
|
||||
|
||||
return true;
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error during subscription update for {Email}", customerEmail);
|
||||
return Result.Fail(new Error("Unexpected error during subscription update.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> HandleSubscriptionDeletedAsync(string customerEmail)
|
||||
public async Task<Result> HandleSubscriptionDeletedAsync(string customerEmail)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||
if (user == null)
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Attempted to delete subscription for non-existent user: {Email}", customerEmail);
|
||||
return false;
|
||||
}
|
||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to delete subscription for non-existent user: {Email}", customerEmail);
|
||||
return Result.Fail($"User {customerEmail} not found.");
|
||||
}
|
||||
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var freePlan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
|
||||
if (freePlan != null)
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var freePlan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
|
||||
if (freePlan != null)
|
||||
{
|
||||
user.SubscriptionPlanId = freePlan.Id;
|
||||
user.AITokenLimit = freePlan.AITokenLimit;
|
||||
}
|
||||
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
_logger.LogError("Failed to reset user {Email} to Free tier after subscription deletion: {Errors}",
|
||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
return Result.Fail(result.Errors.Select(e => e.Description).FirstOrDefault() ?? "Failed to reset user to free tier.");
|
||||
}
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
user.SubscriptionPlanId = freePlan.Id;
|
||||
user.AITokenLimit = freePlan.AITokenLimit;
|
||||
_logger.LogError(ex, "Unexpected error during subscription deletion for {Email}", customerEmail);
|
||||
return Result.Fail(new Error("Unexpected error during subscription deletion.").CausedBy(ex));
|
||||
}
|
||||
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
_logger.LogError("Failed to reset user {Email} to Free tier after subscription deletion: {Errors}",
|
||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Infrastructure implementation of book storage using local filesystem.
|
||||
/// All paths returned are relative to the web root.
|
||||
/// </summary>
|
||||
public class BookStorageService : IBookStorageService
|
||||
{
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
public BookStorageService(IWebHostEnvironment environment)
|
||||
{
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
public async Task<string> SaveEbookAsync(byte[] data, string fileName)
|
||||
{
|
||||
using var stream = new MemoryStream(data);
|
||||
return await SaveEbookAsync(stream, fileName);
|
||||
}
|
||||
|
||||
public async Task<string> SaveEbookAsync(Stream data, string fileName)
|
||||
{
|
||||
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads");
|
||||
EnsureDirectoryExists(uploadsFolder);
|
||||
|
||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
|
||||
|
||||
using (var fileStream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await data.CopyToAsync(fileStream);
|
||||
}
|
||||
|
||||
// Use forward-slash explicitly: Path.Combine produces backslashes on Windows
|
||||
// which would cause 404s when stored as web-relative paths.
|
||||
return $"uploads/{uniqueFileName}";
|
||||
}
|
||||
|
||||
public async Task<string?> SaveCoverAsync(byte[] data, string fileName)
|
||||
{
|
||||
if (data == null || data.Length == 0) return null;
|
||||
using var stream = new MemoryStream(data);
|
||||
return await SaveCoverAsync(stream, fileName);
|
||||
}
|
||||
|
||||
public async Task<string?> SaveCoverAsync(Stream data, string fileName)
|
||||
{
|
||||
var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
|
||||
EnsureDirectoryExists(coversFolder);
|
||||
|
||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||
var filePath = Path.Combine(coversFolder, uniqueFileName);
|
||||
|
||||
using (var fileStream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await data.CopyToAsync(fileStream);
|
||||
}
|
||||
|
||||
return $"covers/{uniqueFileName}";
|
||||
}
|
||||
|
||||
private void EnsureDirectoryExists(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Queries.Reader;
|
||||
using VersOne.Epub;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata (title, author, cover image) from an EPUB stream without persisting anything.
|
||||
/// Used by the ingestion UI before the user confirms the upload.
|
||||
/// </summary>
|
||||
public class EpubMetadataExtractor : IEpubMetadataExtractor
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<LocalEpubMetadata>> ExtractMetadataAsync(Stream epubStream)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bookRef = await EpubReader.OpenBookAsync(epubStream);
|
||||
var title = bookRef.Title ?? "Unknown Title";
|
||||
var author = bookRef.Author ?? "Unknown Author";
|
||||
var description = bookRef.Description;
|
||||
byte[]? cover = await bookRef.ReadCoverAsync();
|
||||
return Result.Ok(new LocalEpubMetadata { Title = title, Author = author, CoverImage = cover, Description = description });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-60
@@ -1,51 +1,64 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Queries.Reader;
|
||||
using NexusReader.Data.Persistence;
|
||||
using VersOne.Epub;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
public class EpubService : IEpubService
|
||||
/// <summary>
|
||||
/// Reads and parses EPUB files from the storage path recorded in the database.
|
||||
/// </summary>
|
||||
public class EpubReaderService : IEpubReader
|
||||
{
|
||||
private const string EpubPath = "wwwroot/assets/book.epub";
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly ILogger<EpubReaderService> _logger;
|
||||
private const int WordThreshold = 1000;
|
||||
|
||||
public async Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex)
|
||||
public EpubReaderService(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
ILogger<EpubReaderService> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<ReaderPageViewModel>> GetEpubContentAsync(
|
||||
Guid ebookId,
|
||||
int chapterIndex,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Path handling: Recursive search upwards to find the asset in development or production
|
||||
var relativePath = Path.Combine("wwwroot", "assets", "book.epub");
|
||||
string? fullPath = null;
|
||||
var searchPaths = new List<string>();
|
||||
// 1. Resolve the file path from the database
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var currentDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
|
||||
while (currentDir != null)
|
||||
var ebook = await context.Ebooks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
e => e.Id == ebookId && (userId == null || e.UserId == userId),
|
||||
cancellationToken);
|
||||
|
||||
if (ebook == null)
|
||||
{
|
||||
var checkPath1 = Path.Combine(currentDir.FullName, relativePath);
|
||||
var checkPath2 = Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New", relativePath);
|
||||
|
||||
searchPaths.Add(checkPath1);
|
||||
if (File.Exists(checkPath1)) { fullPath = checkPath1; break; }
|
||||
|
||||
searchPaths.Add(checkPath2);
|
||||
if (File.Exists(checkPath2)) { fullPath = checkPath2; break; }
|
||||
|
||||
currentDir = currentDir.Parent;
|
||||
return Result.Fail($"Ebook '{ebookId}' not found for user '{userId}'.");
|
||||
}
|
||||
|
||||
if (fullPath == null)
|
||||
// FilePath is stored as a web-relative path (e.g. "uploads/guid_title.epub").
|
||||
// Resolve against the content root, then against the wwwroot sub-directory.
|
||||
var fullPath = ResolvePath(ebook.FilePath);
|
||||
if (fullPath == null || !File.Exists(fullPath))
|
||||
{
|
||||
return Result.Fail($"EPUB file not found. Checked {searchPaths.Count} locations, including: {string.Join(", ", searchPaths.Take(3))}");
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist.");
|
||||
_logger.LogError("EPUB file for ebook {EbookId} not found at path '{FilePath}'.", ebookId, ebook.FilePath);
|
||||
return Result.Fail($"The EPUB file for this book could not be found on the server.");
|
||||
}
|
||||
|
||||
// 2. Parse the EPUB
|
||||
using var bookRef = await EpubReader.OpenBookAsync(fullPath);
|
||||
var readingOrder = bookRef.GetReadingOrder();
|
||||
|
||||
@@ -54,15 +67,12 @@ public class EpubService : IEpubService
|
||||
return Result.Fail("The EPUB has no readable content files in ReadingOrder.");
|
||||
}
|
||||
|
||||
// Ensure index is within bounds
|
||||
if (chapterIndex < 0 || chapterIndex >= readingOrder.Count)
|
||||
{
|
||||
chapterIndex = 0; // Default to first chapter
|
||||
chapterIndex = 0;
|
||||
}
|
||||
|
||||
var chapterRef = readingOrder[chapterIndex];
|
||||
|
||||
// Try to find a better title from navigation (TOC)
|
||||
var navigation = bookRef.GetNavigation();
|
||||
var chapterTitle = FindTitleInNavigation(navigation, chapterRef.FilePath)
|
||||
?? Path.GetFileNameWithoutExtension(chapterRef.FilePath)
|
||||
@@ -70,6 +80,7 @@ public class EpubService : IEpubService
|
||||
|
||||
var chapterContent = await chapterRef.ReadContentAsTextAsync();
|
||||
|
||||
// 3. Build content blocks
|
||||
var blocks = new List<ContentBlock>();
|
||||
int totalWordCount = 0;
|
||||
int blockCounter = 0;
|
||||
@@ -80,13 +91,11 @@ public class EpubService : IEpubService
|
||||
var sanitizedContent = SanitizeParagraph(p);
|
||||
if (string.IsNullOrWhiteSpace(sanitizedContent)) continue;
|
||||
|
||||
// Requirement: Each paragraph mapped to its own TextSegmentBlock
|
||||
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
|
||||
|
||||
int wordsInP = CountWords(sanitizedContent);
|
||||
totalWordCount += wordsInP;
|
||||
|
||||
// Requirement: Smart Injection after 1000 words
|
||||
if (totalWordCount >= WordThreshold)
|
||||
{
|
||||
blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}"));
|
||||
@@ -94,28 +103,51 @@ public class EpubService : IEpubService
|
||||
}
|
||||
}
|
||||
|
||||
// End of chapter section trigger
|
||||
if (blocks.Any() && blocks.Last() is not AiActionTriggerBlock)
|
||||
{
|
||||
blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}"));
|
||||
}
|
||||
|
||||
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle));
|
||||
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle, ebook.Id));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process EPUB for ebook {EbookId}.", ebookId);
|
||||
return Result.Fail(new Error($"Failed to process EPUB: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> ExtractParagraphs(string html)
|
||||
/// <summary>
|
||||
/// Attempts to resolve a web-relative storage path to an absolute filesystem path.
|
||||
/// Searches upward from the app base directory to handle both dev and production layouts.
|
||||
/// </summary>
|
||||
private static string? ResolvePath(string relativePath)
|
||||
{
|
||||
// Normalize forward-slashes to OS separator for file system access
|
||||
var normalized = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
|
||||
var currentDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
|
||||
while (currentDir != null)
|
||||
{
|
||||
var candidate = Path.Combine(currentDir.FullName, "wwwroot", normalized);
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
|
||||
// Also try src/NexusReader.Web/wwwroot (development layout)
|
||||
var devCandidate = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", "wwwroot", normalized);
|
||||
if (File.Exists(devCandidate)) return devCandidate;
|
||||
|
||||
currentDir = currentDir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> ExtractParagraphs(string html)
|
||||
{
|
||||
var bodyMatch = Regex.Match(html, @"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
|
||||
|
||||
var paragraphs = new List<string>();
|
||||
// Match block-level elements: h1-h6, p, ul, ol, blockquote, pre
|
||||
// We match the whole tag to preserve it for sanitization
|
||||
var matches = Regex.Matches(content, @"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
foreach (Match match in matches)
|
||||
@@ -123,7 +155,6 @@ public class EpubService : IEpubService
|
||||
paragraphs.Add(match.Value);
|
||||
}
|
||||
|
||||
// Fallback: split by double newlines if no block tags found
|
||||
if (paragraphs.Count == 0)
|
||||
{
|
||||
paragraphs = content.Split(new[] { "<br />", "<br>", "\n\n", "\r\n\r\n" }, StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||
@@ -132,56 +163,43 @@ public class EpubService : IEpubService
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
private string SanitizeParagraph(string html)
|
||||
private static string SanitizeParagraph(string html)
|
||||
{
|
||||
// 1. Remove <style> and <script> blocks
|
||||
var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?</\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
// 2. Remove all tags except allowed structural and formatting tags
|
||||
clean = Regex.Replace(clean, @"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b)[^>]+>", "", RegexOptions.IgnoreCase);
|
||||
|
||||
// 3. Requirement: Aggressively strip attributes (class, style, id) from allowed tags
|
||||
clean = Regex.Replace(clean, @"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", "<$1>", RegexOptions.IgnoreCase);
|
||||
|
||||
// 4. Decode HTML entities
|
||||
clean = System.Net.WebUtility.HtmlDecode(clean);
|
||||
|
||||
return clean.Trim();
|
||||
}
|
||||
|
||||
private int CountWords(string text)
|
||||
private static int CountWords(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return 0;
|
||||
return text.Split(new[] { ' ', '\r', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries).Length;
|
||||
}
|
||||
|
||||
private AiActionTriggerBlock CreateAiTrigger(string id)
|
||||
{
|
||||
return new AiActionTriggerBlock(
|
||||
id,
|
||||
private static AiActionTriggerBlock CreateAiTrigger(string id) =>
|
||||
new(id,
|
||||
"Wykryto ciekawy fragment! Czy chcesz, abym wygenerował podsumowanie lub quiz z tego rozdziału?",
|
||||
new List<string> { "Podsumuj", "Generuj Quiz", "Pomiń" }
|
||||
);
|
||||
}
|
||||
new List<string> { "Podsumuj", "Generuj Quiz", "Pomiń" });
|
||||
|
||||
private string? FindTitleInNavigation(IEnumerable<EpubNavigationItemRef> navigation, string? filePath)
|
||||
private static string? FindTitleInNavigation(IEnumerable<EpubNavigationItemRef> navigation, string? filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath)) return null;
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
foreach (var item in navigation)
|
||||
{
|
||||
// Match by full path or just filename as fallback
|
||||
if (item.Link?.ContentFilePath == filePath || item.Link?.ContentFilePath == fileName)
|
||||
return item.Title;
|
||||
|
||||
if (item.NestedItems != null && item.NestedItems.Any())
|
||||
if (item.NestedItems?.Any() == true)
|
||||
{
|
||||
var childTitle = FindTitleInNavigation(item.NestedItems, filePath);
|
||||
if (childTitle != null) return childTitle;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Text.Json;
|
||||
using System.Collections.Concurrent;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.ML.Tokenizers;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
@@ -12,59 +14,72 @@ using Polly;
|
||||
using Polly.Registry;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
using Pgvector;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using Qdrant.Client;
|
||||
using Qdrant.Client.Grpc;
|
||||
using Neo4j.Driver;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
public class KnowledgeService : IKnowledgeService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private readonly IChatClient _chatClient;
|
||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly ResiliencePipeline _retryPipeline;
|
||||
private readonly AiSettings _settings;
|
||||
private readonly Tokenizer _tokenizer;
|
||||
private const string PromptVersion = "1.0";
|
||||
private readonly ILogger<KnowledgeService> _logger;
|
||||
private readonly QdrantClient _qdrantClient;
|
||||
private readonly IDriver _neo4jDriver;
|
||||
private const string PromptVersion = "1.3";
|
||||
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
||||
|
||||
public KnowledgeService(
|
||||
IChatClient chatClient,
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
ResiliencePipelineProvider<string> pipelineProvider,
|
||||
IOptions<AiSettings> settings)
|
||||
IOptions<AiSettings> settings,
|
||||
ILogger<KnowledgeService> logger,
|
||||
QdrantClient qdrantClient,
|
||||
IDriver neo4jDriver)
|
||||
{
|
||||
_chatClient = chatClient;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
_qdrantClient = qdrantClient;
|
||||
_neo4jDriver = neo4jDriver;
|
||||
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
||||
// a very reliable estimation for token usage in Gemini-based workloads.
|
||||
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
||||
}
|
||||
|
||||
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", cancellationToken);
|
||||
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", ebookId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.GraphExtractionPrompt, "graph", cancellationToken);
|
||||
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.GraphExtractionPrompt, "graph", ebookId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", cancellationToken);
|
||||
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", ebookId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default)
|
||||
public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken);
|
||||
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", ebookId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string traceType, CancellationToken cancellationToken)
|
||||
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string traceType, Guid? ebookId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return Result.Fail("Input text is empty.");
|
||||
|
||||
@@ -78,18 +93,64 @@ public class KnowledgeService : IKnowledgeService
|
||||
|
||||
if (cached != null && cached.PromptVersion == PromptVersion)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] Cache Hit for {traceType} ({hash})");
|
||||
_logger.LogDebug("[KnowledgeService] Cache Hit for {TraceType} ({Hash})", traceType, hash);
|
||||
try
|
||||
{
|
||||
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, JsonOptions);
|
||||
if (packet != null) return Result.Ok(packet);
|
||||
}
|
||||
catch { /* fallback to regen */ }
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[KnowledgeService] Cached JSON for {Hash} was invalid; regenerating.", hash);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[KnowledgeService] Cache Miss for {traceType} ({hash}). Requesting AI...");
|
||||
// Deduplicate concurrent active requests for the exact same hash
|
||||
var requestKey = $"{tenantId}:{hash}:{traceType}";
|
||||
|
||||
var lazyTask = _activeRequests.GetOrAdd(requestKey, k =>
|
||||
new Lazy<Task<Result<KnowledgePacket>>>(
|
||||
() => ExecuteAiRequestAndCacheAsync(normalizedText, tenantId, systemPrompt, traceType, ebookId, hash),
|
||||
System.Threading.LazyThreadSafetyMode.ExecutionAndPublication
|
||||
));
|
||||
|
||||
try
|
||||
{
|
||||
var result = await lazyTask.Value;
|
||||
|
||||
// If the AI call returned a failure, remove it from the active dictionary
|
||||
// so subsequent retries have a chance to request the AI again.
|
||||
if (result.IsFailed)
|
||||
{
|
||||
_activeRequests.TryRemove(requestKey, out _);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_activeRequests.TryRemove(requestKey, out _);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_activeRequests.TryRemove(requestKey, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Result<KnowledgePacket>> ExecuteAiRequestAndCacheAsync(
|
||||
string normalizedText,
|
||||
string tenantId,
|
||||
string systemPrompt,
|
||||
string traceType,
|
||||
Guid? ebookId,
|
||||
string hash)
|
||||
{
|
||||
_logger.LogInformation("[KnowledgeService] Cache Miss for {TraceType} ({Hash}). Requesting AI...", traceType, hash);
|
||||
try
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Temperature = (float)_settings.Temperature,
|
||||
@@ -101,7 +162,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
{
|
||||
new ChatMessage(ChatRole.System, systemPrompt),
|
||||
new ChatMessage(ChatRole.User, normalizedText)
|
||||
}, options, cancellationToken: ct), cancellationToken);
|
||||
}, options, cancellationToken: ct));
|
||||
|
||||
var rawResponse = response.Text?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(rawResponse)) return Result.Fail("AI returned an empty response.");
|
||||
@@ -112,24 +173,13 @@ public class KnowledgeService : IKnowledgeService
|
||||
|
||||
try
|
||||
{
|
||||
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, JsonOptions);
|
||||
if (knowledgePacket == null) return Result.Fail("Failed to deserialize AI response.");
|
||||
|
||||
// 3. Generate Embedding if not present
|
||||
float[]? vector = null;
|
||||
try
|
||||
{
|
||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
await _embeddingGenerator.GenerateAsync(new[] { normalizedText }, cancellationToken: ct), cancellationToken);
|
||||
vector = embeddingResponse.First().Vector.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] Embedding Error: {ex.Message}");
|
||||
// We continue even if embedding fails, as the primary goal was knowledge extraction
|
||||
}
|
||||
|
||||
// 4. Save to Cache
|
||||
var cached = await dbContext.SemanticKnowledgeCache
|
||||
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId);
|
||||
|
||||
var cacheEntry = new SemanticKnowledgeCache
|
||||
{
|
||||
ContentHash = hash,
|
||||
@@ -138,7 +188,6 @@ public class KnowledgeService : IKnowledgeService
|
||||
ModelId = _settings.Model,
|
||||
PromptVersion = PromptVersion,
|
||||
TenantId = tenantId,
|
||||
Vector = vector != null ? new Vector(vector) : null,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
@@ -147,19 +196,18 @@ public class KnowledgeService : IKnowledgeService
|
||||
{
|
||||
cached.JsonData = jsonResponse;
|
||||
cached.OriginalText = normalizedText;
|
||||
cached.Vector = vector != null ? new Vector(vector) : null;
|
||||
cached.CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// 5. Process structured KnowledgeUnits (Graph Expansion)
|
||||
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, dbContext, cancellationToken);
|
||||
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Result.Ok(knowledgePacket);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] JSON Error: {ex.Message}. Raw length: {rawResponse.Length}");
|
||||
_logger.LogError(ex, "[KnowledgeService] JSON deserialization error. Raw response length: {Length}", rawResponse.Length);
|
||||
return Result.Fail($"Failed to deserialize AI response: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -167,9 +215,15 @@ public class KnowledgeService : IKnowledgeService
|
||||
{
|
||||
return Result.Fail(new Error("Failed to extract knowledge from AI").CausedBy(ex));
|
||||
}
|
||||
finally
|
||||
{
|
||||
var requestKey = $"{tenantId}:{hash}:{traceType}";
|
||||
_activeRequests.TryRemove(requestKey, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, AppDbContext dbContext, CancellationToken cancellationToken)
|
||||
|
||||
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken)
|
||||
{
|
||||
var unitIds = packet.Units.Select(u => u.Id).ToList();
|
||||
var linkSourceIds = packet.Links.Select(l => l.Source).ToList();
|
||||
@@ -198,16 +252,14 @@ public class KnowledgeService : IKnowledgeService
|
||||
|
||||
unit.Type = Enum.TryParse<NexusReader.Domain.Enums.KnowledgeUnitType>(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet;
|
||||
unit.Content = unitDto.Content;
|
||||
unit.SourceId = "extracted";
|
||||
|
||||
// Link to the specific ebook if provided.
|
||||
// Link to ebook if provided
|
||||
unit.EbookId = ebookId;
|
||||
|
||||
unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata);
|
||||
|
||||
try
|
||||
{
|
||||
var emb = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, cancellationToken: ct), cancellationToken);
|
||||
unit.Vector = new Vector(emb.First().Vector.ToArray());
|
||||
}
|
||||
catch { /* Ignore embedding errors for now */ }
|
||||
// Embeddings and vector storage are handled via Qdrant in the new pipeline.
|
||||
|
||||
processedUnitIds.Add(unit.Id);
|
||||
}
|
||||
@@ -231,9 +283,101 @@ public class KnowledgeService : IKnowledgeService
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] WARNING: Skipping invalid link {linkDto.Source} -> {linkDto.Target} (Missing units).");
|
||||
_logger.LogWarning("[KnowledgeService] Skipping invalid link {Source} -> {Target}: one or both units are missing.", linkDto.Source, linkDto.Target);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate and upsert vectors to Qdrant in batch
|
||||
var unitsToEmbed = packet.Units
|
||||
.Where(u => !string.IsNullOrEmpty(u.Content))
|
||||
.ToList();
|
||||
|
||||
if (unitsToEmbed.Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
var contents = unitsToEmbed.Select(u => u.Content).ToList();
|
||||
|
||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
await _embeddingGenerator.GenerateAsync(
|
||||
contents,
|
||||
new EmbeddingGenerationOptions { Dimensions = 768 },
|
||||
cancellationToken: ct), cancellationToken);
|
||||
|
||||
var embeddings = embeddingResponse.ToList();
|
||||
var points = new List<PointStruct>();
|
||||
|
||||
for (int i = 0; i < unitsToEmbed.Count; i++)
|
||||
{
|
||||
var unitDto = unitsToEmbed[i];
|
||||
var vector = embeddings[i].Vector.ToArray();
|
||||
|
||||
var point = new PointStruct
|
||||
{
|
||||
Id = GetDeterministicGuid(unitDto.Id),
|
||||
Vectors = vector,
|
||||
Payload =
|
||||
{
|
||||
["content"] = unitDto.Content,
|
||||
["type"] = unitDto.Type ?? string.Empty,
|
||||
["tenantId"] = tenantId,
|
||||
["ebookId"] = ebookId?.ToString() ?? string.Empty,
|
||||
["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata)
|
||||
}
|
||||
};
|
||||
points.Add(point);
|
||||
}
|
||||
|
||||
if (points.Any())
|
||||
{
|
||||
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||
await _qdrantClient.UpsertAsync("knowledge_units", points, cancellationToken: cancellationToken);
|
||||
_logger.LogInformation("[KnowledgeService] Successfully upserted {Count} points to Qdrant collection 'knowledge_units'.", points.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[KnowledgeService] Failed to generate and upsert embeddings for knowledge units to Qdrant.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
_logger.LogInformation("[KnowledgeService] Creating Qdrant collection '{CollectionName}'...", collectionName);
|
||||
await _qdrantClient.CreateCollectionAsync(
|
||||
collectionName: collectionName,
|
||||
vectorsConfig: new VectorParams
|
||||
{
|
||||
Size = 768,
|
||||
Distance = Distance.Cosine
|
||||
},
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
_logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' created successfully.", collectionName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName);
|
||||
}
|
||||
}
|
||||
|
||||
private static Guid GetDeterministicGuid(string input)
|
||||
{
|
||||
if (Guid.TryParse(input, out var guid))
|
||||
{
|
||||
return guid;
|
||||
}
|
||||
|
||||
using var md5 = System.Security.Cryptography.MD5.Create();
|
||||
byte[] hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
return new Guid(hash);
|
||||
}
|
||||
|
||||
public async Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default)
|
||||
@@ -264,7 +408,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
var rawJson = response.Text?.Trim() ?? "{}";
|
||||
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim();
|
||||
|
||||
var result = JsonSerializer.Deserialize<GroundednessResult>(rawJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
var result = JsonSerializer.Deserialize<GroundednessResult>(rawJson, JsonOptions);
|
||||
|
||||
return result != null ? Result.Ok(result) : Result.Fail("Failed to parse groundedness result");
|
||||
}
|
||||
@@ -276,21 +420,55 @@ public class KnowledgeService : IKnowledgeService
|
||||
|
||||
public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var queryEmbedding = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
await _embeddingGenerator.GenerateAsync(new[] { query }, cancellationToken: ct), cancellationToken);
|
||||
var queryVector = new Vector(queryEmbedding.First().Vector.ToArray());
|
||||
var queryVector = queryEmbedding.First().Vector.ToArray();
|
||||
|
||||
var relevantUnits = await dbContext.KnowledgeUnits
|
||||
.Where(u => u.TenantId == tenantId)
|
||||
.OrderBy(u => u.Vector!.L2Distance(queryVector))
|
||||
.Take(5)
|
||||
.Select(u => new RelevantContext { Text = u.Content, Confidence = 1.0 })
|
||||
.ToListAsync(cancellationToken);
|
||||
var filter = new Qdrant.Client.Grpc.Filter();
|
||||
filter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "tenantId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = tenantId }
|
||||
}
|
||||
});
|
||||
filter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "tenantId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = "global" }
|
||||
}
|
||||
});
|
||||
|
||||
return Result.Ok(relevantUnits);
|
||||
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
|
||||
try
|
||||
{
|
||||
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||
var response = await _qdrantClient.SearchAsync(
|
||||
collectionName: "knowledge_units",
|
||||
vector: queryVector,
|
||||
filter: filter,
|
||||
limit: 5,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
searchResult = response.ToList();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
|
||||
}
|
||||
|
||||
var contexts = searchResult.Select(point => new RelevantContext
|
||||
{
|
||||
Text = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty,
|
||||
Confidence = point.Score
|
||||
}).ToList();
|
||||
|
||||
return Result.Ok(contexts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -298,6 +476,408 @@ public class KnowledgeService : IKnowledgeService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Generate 768-dimensional embedding
|
||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
await _embeddingGenerator.GenerateAsync(
|
||||
new[] { queryText },
|
||||
new EmbeddingGenerationOptions { Dimensions = 768 },
|
||||
cancellationToken: ct), cancellationToken);
|
||||
|
||||
var queryVector = embeddingResponse.First().Vector.ToArray();
|
||||
|
||||
// 2. Query Qdrant
|
||||
var filter = new Qdrant.Client.Grpc.Filter();
|
||||
filter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "tenantId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = tenantId }
|
||||
}
|
||||
});
|
||||
filter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "tenantId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = "global" }
|
||||
}
|
||||
});
|
||||
|
||||
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
|
||||
try
|
||||
{
|
||||
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||
var response = await _qdrantClient.SearchAsync(
|
||||
collectionName: "knowledge_units",
|
||||
vector: queryVector,
|
||||
filter: filter,
|
||||
limit: (ulong)limit,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
searchResult = response.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[KnowledgeService] Failed to search in Qdrant; collection might not exist yet.");
|
||||
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
|
||||
}
|
||||
|
||||
if (!searchResult.Any())
|
||||
{
|
||||
return Result.Ok(new List<SemanticSearchResultDto>());
|
||||
}
|
||||
|
||||
// 3. Graph Expansion via Neo4j
|
||||
var candidateIds = searchResult.Select(r => r.Id.ToString()).ToList();
|
||||
var definitions = new Dictionary<string, List<string>>();
|
||||
|
||||
if (candidateIds.Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var session = _neo4jDriver.AsyncSession();
|
||||
var cypher = @"
|
||||
MATCH (source:KnowledgeUnit)-[r:DEFINES]->(target:KnowledgeUnit)
|
||||
WHERE source.id IN $candidateIds
|
||||
RETURN source.id AS sourceId, target.content AS targetContent";
|
||||
|
||||
var neoResult = await session.ExecuteReadAsync(async tx =>
|
||||
{
|
||||
var cursor = await tx.RunAsync(cypher, new { candidateIds });
|
||||
return await cursor.ToListAsync();
|
||||
});
|
||||
|
||||
foreach (var record in neoResult)
|
||||
{
|
||||
var sourceId = record["sourceId"].As<string>();
|
||||
var targetContent = record["targetContent"].As<string>();
|
||||
if (!definitions.ContainsKey(sourceId))
|
||||
{
|
||||
definitions[sourceId] = new List<string>();
|
||||
}
|
||||
definitions[sourceId].Add(targetContent);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion query failed.");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Retrieve Ebook Titles from PostgreSQL
|
||||
var ebookIds = searchResult
|
||||
.Where(r => r.Payload.TryGetValue("ebookId", out var ev) && Guid.TryParse(ev.StringValue, out _))
|
||||
.Select(r => Guid.Parse(r.Payload["ebookId"].StringValue))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var ebookTitles = new Dictionary<Guid, string>();
|
||||
if (ebookIds.Any())
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
ebookTitles = await dbContext.Ebooks
|
||||
.Where(e => ebookIds.Contains(e.Id))
|
||||
.ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. Map results to DTOs
|
||||
var dtos = searchResult.Select(point =>
|
||||
{
|
||||
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
|
||||
var type = point.Payload.TryGetValue("type", out var tv) ? tv.StringValue : string.Empty;
|
||||
var ebookIdStr = point.Payload.TryGetValue("ebookId", out var ev) ? ev.StringValue : null;
|
||||
|
||||
Guid? ebookId = null;
|
||||
if (Guid.TryParse(ebookIdStr, out var parsedId))
|
||||
{
|
||||
ebookId = parsedId;
|
||||
}
|
||||
|
||||
string? bookTitle = null;
|
||||
if (ebookId.HasValue)
|
||||
{
|
||||
ebookTitles.TryGetValue(ebookId.Value, out bookTitle);
|
||||
}
|
||||
|
||||
Dictionary<string, object>? metadata = null;
|
||||
if (point.Payload.TryGetValue("metadataJson", out var metaVal) && !string.IsNullOrEmpty(metaVal.StringValue))
|
||||
{
|
||||
try
|
||||
{
|
||||
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
var dto = new SemanticSearchResultDto
|
||||
{
|
||||
ContentHash = point.Id.ToString(),
|
||||
Snippet = content,
|
||||
UnitType = type,
|
||||
RelevanceScore = point.Score,
|
||||
SourceBookTitle = bookTitle,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
var pointIdStr = point.Id.ToString();
|
||||
if (definitions.TryGetValue(pointIdStr, out var pointDefs) && pointDefs.Any())
|
||||
{
|
||||
dto.Snippet = $"[Context: {string.Join("; ", pointDefs)}]\n{dto.Snippet}";
|
||||
}
|
||||
|
||||
return dto;
|
||||
}).ToList();
|
||||
|
||||
return Result.Ok(dtos);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Failed to search library semantically").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<GroundedResponseDto>> AskQuestionAsync(
|
||||
string question,
|
||||
string tenantId,
|
||||
Guid? ebookId = null,
|
||||
int limit = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Generate 768-dimensional embedding for the question
|
||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
await _embeddingGenerator.GenerateAsync(
|
||||
new[] { question },
|
||||
new EmbeddingGenerationOptions { Dimensions = 768 },
|
||||
cancellationToken: ct), cancellationToken);
|
||||
|
||||
var queryVector = embeddingResponse.First().Vector.ToArray();
|
||||
|
||||
// 2. Query Qdrant with filters
|
||||
var filter = new Qdrant.Client.Grpc.Filter();
|
||||
|
||||
// Tenant filter (must match tenantId OR "global")
|
||||
var tenantFilter = new Qdrant.Client.Grpc.Filter();
|
||||
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "tenantId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = tenantId }
|
||||
}
|
||||
});
|
||||
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "tenantId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = "global" }
|
||||
}
|
||||
});
|
||||
filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = tenantFilter });
|
||||
|
||||
if (ebookId.HasValue)
|
||||
{
|
||||
filter.Must.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "ebookId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = ebookId.Value.ToString() }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
|
||||
try
|
||||
{
|
||||
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||
var response = await _qdrantClient.SearchAsync(
|
||||
collectionName: "knowledge_units",
|
||||
vector: queryVector,
|
||||
filter: filter,
|
||||
limit: (ulong)limit,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
searchResult = response.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[KnowledgeService] Qdrant search failed during RAG retrieval.");
|
||||
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
|
||||
}
|
||||
|
||||
if (!searchResult.Any())
|
||||
{
|
||||
return Result.Ok(new GroundedResponseDto
|
||||
{
|
||||
Answer = "I cannot answer this based on the provided book context.",
|
||||
Citations = new List<CitationDto>()
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Graph Expansion via Neo4j
|
||||
var candidateIds = searchResult.Select(r => r.Id.ToString()).ToList();
|
||||
var relatedContexts = new List<string>();
|
||||
|
||||
// Keep map of point ID -> payload data for fast mapping later
|
||||
var pointMap = searchResult.ToDictionary(r => r.Id.ToString(), r => r);
|
||||
|
||||
if (candidateIds.Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var session = _neo4jDriver.AsyncSession();
|
||||
var cypher = @"
|
||||
MATCH (source:KnowledgeUnit)
|
||||
WHERE source.id IN $candidateIds
|
||||
OPTIONAL MATCH (source)-[r:DEFINES|RELATED_TO]->(target:KnowledgeUnit)
|
||||
RETURN source.id AS sourceId, source.content AS sourceContent,
|
||||
collect({ targetId: target.id, targetContent: target.content, relation: type(r) }) AS relations";
|
||||
|
||||
var neoResult = await session.ExecuteReadAsync(async tx =>
|
||||
{
|
||||
var cursor = await tx.RunAsync(cypher, new { candidateIds });
|
||||
return await cursor.ToListAsync();
|
||||
});
|
||||
|
||||
foreach (var record in neoResult)
|
||||
{
|
||||
var sourceId = record["sourceId"].As<string>();
|
||||
var sourceContent = record["sourceContent"].As<string>();
|
||||
|
||||
relatedContexts.Add($"[Source ID: {sourceId}] {sourceContent}");
|
||||
|
||||
var relations = record["relations"].As<List<object>>();
|
||||
if (relations != null)
|
||||
{
|
||||
foreach (var relObj in relations)
|
||||
{
|
||||
if (relObj is Dictionary<string, object> relDict &&
|
||||
relDict.TryGetValue("targetId", out var targetIdVal) && targetIdVal is string targetId &&
|
||||
relDict.TryGetValue("targetContent", out var targetContentVal) && targetContentVal is string targetContent &&
|
||||
relDict.TryGetValue("relation", out var relationVal) && relationVal is string relation)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(targetContent))
|
||||
{
|
||||
relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetContent}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion failed. Falling back to direct Qdrant points.");
|
||||
foreach (var point in searchResult)
|
||||
{
|
||||
var sourceId = point.Id.ToString();
|
||||
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
|
||||
relatedContexts.Add($"[Source ID: {sourceId}] {content}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Retrieve Book Titles from PostgreSQL to populate citations
|
||||
var ebookIds = searchResult
|
||||
.Where(r => r.Payload.TryGetValue("ebookId", out var ev) && Guid.TryParse(ev.StringValue, out _))
|
||||
.Select(r => Guid.Parse(r.Payload["ebookId"].StringValue))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var ebookTitles = new Dictionary<Guid, string>();
|
||||
if (ebookIds.Any())
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
ebookTitles = await dbContext.Ebooks
|
||||
.Where(e => ebookIds.Contains(e.Id))
|
||||
.ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. Build prompt and invoke Gemini with structured JSON formatting
|
||||
var contextBlocksText = string.Join("\n\n", relatedContexts);
|
||||
|
||||
var systemPrompt = @"
|
||||
You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks.
|
||||
|
||||
Strict Grounding Rules:
|
||||
1. Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions.
|
||||
2. If the context does not contain the answer, you must state exactly: 'I cannot answer this based on the provided book context.'
|
||||
3. For every statement or claim you make in your answer, you must cite the specific source IDs (e.g., source chunk ID or hash) from the context.
|
||||
4. You must format your response ONLY as a JSON object matching the following structure:
|
||||
{
|
||||
""answer"": ""The answer text goes here, referencing [Source ID] as citations."",
|
||||
""citations"": [
|
||||
{
|
||||
""citationId"": ""The exact source ID cited (e.g., chunk hash/ID)"",
|
||||
""snippet"": ""The precise sentence or phrase from the context that supports this statement."",
|
||||
""sourceBook"": ""The book title or 'Unknown'""
|
||||
}
|
||||
]
|
||||
}
|
||||
";
|
||||
|
||||
var userPrompt = $"Context:\n{contextBlocksText}\n\nQuestion: {question}";
|
||||
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Temperature = 0.0f,
|
||||
MaxOutputTokens = 1500,
|
||||
ResponseFormat = ChatResponseFormat.Json
|
||||
};
|
||||
|
||||
var chatResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
await _chatClient.GetResponseAsync(new List<ChatMessage>
|
||||
{
|
||||
new ChatMessage(ChatRole.System, systemPrompt),
|
||||
new ChatMessage(ChatRole.User, userPrompt)
|
||||
}, options, cancellationToken: ct), cancellationToken);
|
||||
|
||||
var rawJson = chatResponse.Text?.Trim() ?? string.Empty;
|
||||
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim();
|
||||
rawJson = JsonRepairHelper.Repair(rawJson);
|
||||
|
||||
try
|
||||
{
|
||||
var groundedResult = JsonSerializer.Deserialize<GroundedResponseDto>(rawJson, JsonOptions);
|
||||
if (groundedResult == null || string.IsNullOrWhiteSpace(groundedResult.Answer))
|
||||
{
|
||||
return Result.Fail("Failed to deserialize grounded RAG response.");
|
||||
}
|
||||
|
||||
// Hydrate book titles for citations if unknown
|
||||
foreach (var citation in groundedResult.Citations)
|
||||
{
|
||||
if (pointMap.TryGetValue(citation.CitationId, out var point) &&
|
||||
point.Payload.TryGetValue("ebookId", out var ev) &&
|
||||
Guid.TryParse(ev.StringValue, out var ebId) &&
|
||||
ebookTitles.TryGetValue(ebId, out var title))
|
||||
{
|
||||
citation.SourceBook = title;
|
||||
}
|
||||
}
|
||||
|
||||
return Result.Ok(groundedResult);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "[KnowledgeService] JSON deserialization failed for grounding response. Raw text: {Text}", rawJson);
|
||||
return Result.Fail($"Failed to parse AI grounded response: {ex.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Failed to execute RAG retrieval flow").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
@@ -306,6 +886,16 @@ public class KnowledgeService : IKnowledgeService
|
||||
await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await _qdrantClient.DeleteCollectionAsync("knowledge_units", cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[KnowledgeService] Failed to drop Qdrant collection 'knowledge_units' during cache clear.");
|
||||
}
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -4,6 +4,9 @@ public static class PromptRegistry
|
||||
{
|
||||
public const string KnowledgeExtractionSystemPrompt =
|
||||
"You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " +
|
||||
"CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' instead of full sentences). " +
|
||||
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " +
|
||||
"CRITICAL: Code blocks (e.g., markdown code snippets) must be excluded from the relationship graph, or summarized as a single node (e.g., 'Code Example'). Do NOT create nodes for variables, functions, namespaces, or individual lines of code. " +
|
||||
"CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " +
|
||||
"Schema: { " +
|
||||
"\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " +
|
||||
@@ -12,12 +15,17 @@ public static class PromptRegistry
|
||||
"}.";
|
||||
|
||||
public const string GraphExtractionPrompt =
|
||||
"You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " +
|
||||
"CRITICAL: Each paragraph in the user text starts with [ID: some-id]. You MUST use these exact IDs as the 'id' for the nodes representing those blocks. " +
|
||||
"Include a 'current' node representing the block content itself if applicable. " +
|
||||
"CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " +
|
||||
"You are an expert at information architecture. Extract key concepts and paragraph mappings from the text to build a unified knowledge graph. " +
|
||||
"The input text consists of several paragraphs, each starting with its unique block ID in the format '[ID: seg-X]'. " +
|
||||
"Extract two types of nodes: " +
|
||||
"1. Concept Nodes (group: 'concept'): Extract the main technical concepts discussed (e.g., ID: 'dependency-injection', label: 'Dependency Injection'). Max 10 concepts. Labels must be at most 3 words. " +
|
||||
"2. Block Nodes (group: 'current'): For each paragraph in the input, create a node representing that paragraph where 'id' is the exact block ID (e.g., 'seg-1'), and 'label' is a brief summary of that paragraph's content (max 3 words). " +
|
||||
"CRITICAL: If a paragraph is a code block, represent it as a single block node with label 'Code Example' (group: 'current'). Do NOT extract low-level code elements (like variables, classes, methods, or namespaces) as separate concept nodes. " +
|
||||
"CRITICAL: Connect related concept nodes together, and connect each concept node to the block nodes ('seg-X') where it is discussed. " +
|
||||
"Limit connections to a MAXIMUM of 15 most relevant links. " +
|
||||
"Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }";
|
||||
|
||||
|
||||
public const string SummaryAndQuizPrompt =
|
||||
"You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " +
|
||||
"Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }";
|
||||
@@ -26,6 +34,7 @@ public static class PromptRegistry
|
||||
"You are an expert at Knowledge Engineering. Segment the provided text into discrete Knowledge Units. " +
|
||||
"Identify 'units' (sections, tables, definitions, rules) and 'links' (how they relate). " +
|
||||
"CRITICAL: Units must be granular. " +
|
||||
"CRITICAL: Code blocks must be summarized under the parent unit or represented as a single 'Code Example' unit. Do NOT segment code blocks into granular low-level code details (e.g., classes, variables, parameters). " +
|
||||
"Schema: { " +
|
||||
"\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " +
|
||||
"\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " +
|
||||
|
||||
@@ -8,4 +8,22 @@ public partial class App : Microsoft.Maui.Controls.Application
|
||||
|
||||
MainPage = new MainPage();
|
||||
}
|
||||
|
||||
protected override Window CreateWindow(IActivationState? activationState)
|
||||
{
|
||||
var window = base.CreateWindow(activationState);
|
||||
|
||||
// Hook into native MAUI lifecycle events to cleanly flush and close Serilog buffers
|
||||
window.Stopped += (s, e) =>
|
||||
{
|
||||
Serilog.Log.CloseAndFlush();
|
||||
};
|
||||
|
||||
window.Destroying += (s, e) =>
|
||||
{
|
||||
Serilog.Log.CloseAndFlush();
|
||||
};
|
||||
|
||||
return window;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Maui.Infrastructure.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// A secure HTTP message delegating handler for MAUI that automatically appends JWT tokens
|
||||
/// to trusted origin requests and transparently refreshes expired tokens in a thread-safe manner.
|
||||
/// </summary>
|
||||
public class MobileAuthenticationHeaderHandler : DelegatingHandler
|
||||
{
|
||||
private readonly INativeStorageService _storageService;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private const string TokenKey = "nexus_auth_token";
|
||||
private static readonly SemaphoreSlim _refreshSemaphore = new(1, 1);
|
||||
|
||||
public MobileAuthenticationHeaderHandler(INativeStorageService storageService, IServiceProvider serviceProvider)
|
||||
{
|
||||
_storageService = storageService;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = request.RequestUri?.AbsolutePath ?? "";
|
||||
bool isAuthEndpoint = path.Contains("identity/login") ||
|
||||
path.Contains("identity/register") ||
|
||||
path.Contains("identity/refresh");
|
||||
|
||||
// Resolve configured API host dynamically to avoid hardcoded IP addresses
|
||||
var config = _serviceProvider.GetRequiredService<IConfiguration>();
|
||||
var apiBaseUrlString = config["ApiSettings:BaseUrl"];
|
||||
string? apiHost = null;
|
||||
if (!string.IsNullOrEmpty(apiBaseUrlString) && Uri.TryCreate(apiBaseUrlString, UriKind.Absolute, out var apiUri))
|
||||
{
|
||||
apiHost = apiUri.Host;
|
||||
}
|
||||
|
||||
// In MAUI, since we only call our own local or staging APIs, we trust local IP/localhost/configured API host.
|
||||
// We ensure we don't accidentally leak tokens to third-party endpoints.
|
||||
bool isTrustedHost = request.RequestUri != null &&
|
||||
(request.RequestUri.Host == "localhost" ||
|
||||
request.RequestUri.Host == "127.0.0.1" ||
|
||||
(apiHost != null && request.RequestUri.Host == apiHost) ||
|
||||
request.RequestUri.Host.EndsWith("nexusreader.com")); // Or staging domains
|
||||
|
||||
string? originalToken = null;
|
||||
|
||||
if (!isAuthEndpoint && isTrustedHost)
|
||||
{
|
||||
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
||||
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
|
||||
{
|
||||
originalToken = tokenResult.Value;
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
|
||||
}
|
||||
}
|
||||
|
||||
var response = await base.SendAsync(request, cancellationToken);
|
||||
|
||||
// Transparent JWT Auto-Refresh on 401 Unauthorized
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !isAuthEndpoint)
|
||||
{
|
||||
await _refreshSemaphore.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Re-read token to verify if another concurrent request already refreshed it
|
||||
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
||||
var currentToken = tokenResult.IsSuccess ? tokenResult.Value : null;
|
||||
|
||||
bool refreshed = false;
|
||||
|
||||
if (!string.IsNullOrEmpty(currentToken) && currentToken != originalToken)
|
||||
{
|
||||
refreshed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var identityService = scope.ServiceProvider.GetRequiredService<IIdentityService>();
|
||||
var refreshResult = await identityService.RefreshTokenAsync();
|
||||
if (refreshResult.IsSuccess)
|
||||
{
|
||||
var newTokenResult = await _storageService.GetSecureString(TokenKey);
|
||||
currentToken = newTokenResult.IsSuccess ? newTokenResult.Value : null;
|
||||
refreshed = !string.IsNullOrEmpty(currentToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await identityService.LogoutAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshed && !string.IsNullOrEmpty(currentToken))
|
||||
{
|
||||
var newRequest = await CloneHttpRequestMessageAsync(request);
|
||||
newRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", currentToken);
|
||||
return await base.SendAsync(newRequest, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "[MobileAuthHandler] Automated token renewal failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<HttpRequestMessage> CloneHttpRequestMessageAsync(HttpRequestMessage req)
|
||||
{
|
||||
var clone = new HttpRequestMessage(req.Method, req.RequestUri)
|
||||
{
|
||||
Version = req.Version
|
||||
};
|
||||
|
||||
if (req.Content != null)
|
||||
{
|
||||
var ms = new System.IO.MemoryStream();
|
||||
await req.Content.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
clone.Content = new StreamContent(ms);
|
||||
|
||||
foreach (var h in req.Content.Headers)
|
||||
{
|
||||
clone.Content.Headers.TryAddWithoutValidation(h.Key, h.Value);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var h in req.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(h.Key, h.Value);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace NexusReader.Maui.Infrastructure.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight bridge service that intercepts logs, console outputs, and uncaught exceptions
|
||||
/// from the Blazor WebView/JS side, and routes them directly to Serilog under "BlazorWebView" context.
|
||||
/// </summary>
|
||||
public sealed class BlazorLoggingBridge
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public BlazorLoggingBridge(ILoggerFactory loggerFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger("BlazorWebView");
|
||||
}
|
||||
|
||||
[JSInvokable("LogJsMessage")]
|
||||
public void LogJsMessage(string level, string message, string? stackTrace = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (level.ToLowerInvariant())
|
||||
{
|
||||
case "error":
|
||||
case "exception":
|
||||
if (!string.IsNullOrWhiteSpace(stackTrace))
|
||||
{
|
||||
_logger.LogError("JS Unhandled Exception: {Message}\nStack Trace:\n{StackTrace}", message, stackTrace);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("JS Error: {Message}", message);
|
||||
}
|
||||
break;
|
||||
|
||||
case "warning":
|
||||
case "warn":
|
||||
_logger.LogWarning("JS Warning: {Message}", message);
|
||||
break;
|
||||
|
||||
case "info":
|
||||
case "log":
|
||||
default:
|
||||
_logger.LogInformation("JS Log: {Message}", message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting;
|
||||
using Serilog.Formatting.Display;
|
||||
|
||||
namespace NexusReader.Maui.Infrastructure.Logging;
|
||||
|
||||
public static class SerilogConfiguration
|
||||
{
|
||||
private const string OutputTemplate =
|
||||
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [ThreadId: {ThreadId}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
|
||||
|
||||
public static MauiAppBuilder RegisterLogging(this MauiAppBuilder builder)
|
||||
{
|
||||
// 1. Ensure logs directory exists in secure sandbox
|
||||
var logDir = Path.Combine(Microsoft.Maui.Storage.FileSystem.AppDataDirectory, "logs");
|
||||
if (!Directory.Exists(logDir))
|
||||
{
|
||||
Directory.CreateDirectory(logDir);
|
||||
}
|
||||
var logPath = Path.Combine(logDir, "log-.txt");
|
||||
|
||||
// 2. Inject sandboxed log path dynamically into configuration provider
|
||||
builder.Configuration["Serilog:WriteTo:0:Args:configure:0:Args:path"] = logPath;
|
||||
|
||||
// 3. Configure Serilog Logger Configuration using App Configuration settings
|
||||
var loggerConfig = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.Enrich.With(new ThreadIdEnricher());
|
||||
|
||||
// 4. Platform-specific and environment-specific sinks
|
||||
#if ANDROID
|
||||
// Direct Native Android Logcat Sink (JNI bindings for native diagnostics)
|
||||
loggerConfig.WriteTo.Sink(
|
||||
new AndroidLogcatSink(new MessageTemplateTextFormatter(OutputTemplate, null)),
|
||||
restrictedToMinimumLevel: LogEventLevel.Debug);
|
||||
#endif
|
||||
|
||||
// 5. Initialize the static Serilog Log
|
||||
Log.Logger = loggerConfig.CreateLogger();
|
||||
|
||||
// 6. Connect Serilog to Microsoft.Extensions.Logging
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSerilog(dispose: true);
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A custom self-contained thread enricher to avoid unnecessary NuGet packages.
|
||||
/// </summary>
|
||||
internal sealed class ThreadIdEnricher : ILogEventEnricher
|
||||
{
|
||||
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("ThreadId", Environment.CurrentManagedThreadId));
|
||||
}
|
||||
}
|
||||
|
||||
#if ANDROID
|
||||
/// <summary>
|
||||
/// A high-performance, direct Android Logcat Sink utilizing native Android APIs.
|
||||
/// </summary>
|
||||
internal sealed class AndroidLogcatSink : ILogEventSink
|
||||
{
|
||||
private readonly ITextFormatter _formatter;
|
||||
private const string Tag = "NexusReader";
|
||||
|
||||
public AndroidLogcatSink(ITextFormatter formatter)
|
||||
{
|
||||
_formatter = formatter ?? throw new ArgumentNullException(nameof(formatter));
|
||||
}
|
||||
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
_formatter.Format(logEvent, writer);
|
||||
var message = writer.ToString().Trim();
|
||||
|
||||
switch (logEvent.Level)
|
||||
{
|
||||
case LogEventLevel.Verbose:
|
||||
Android.Util.Log.Verbose(Tag, message);
|
||||
break;
|
||||
case LogEventLevel.Debug:
|
||||
Android.Util.Log.Debug(Tag, message);
|
||||
break;
|
||||
case LogEventLevel.Information:
|
||||
Android.Util.Log.Info(Tag, message);
|
||||
break;
|
||||
case LogEventLevel.Warning:
|
||||
Android.Util.Log.Warn(Tag, message);
|
||||
break;
|
||||
case LogEventLevel.Error:
|
||||
Android.Util.Log.Error(Tag, message);
|
||||
break;
|
||||
case LogEventLevel.Fatal:
|
||||
Android.Util.Log.Wtf(Tag, message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,9 +1,12 @@
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using NexusReader.UI.Shared
|
||||
@using NexusReader.Maui.Infrastructure.Logging
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject BlazorLoggingBridge LoggingBridge
|
||||
|
||||
<Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(NexusReader.UI.Shared.Layout.MainLayout)">
|
||||
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(NexusReader.UI.Shared.Layout.MainHubLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
@@ -11,8 +14,26 @@
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(NexusReader.UI.Shared.Layout.MainLayout)">
|
||||
<LayoutView Layout="@typeof(NexusReader.UI.Shared.Layout.MainHubLayout)">
|
||||
<p role="alert">Sorry, there's nothing at this address.</p>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
|
||||
@code {
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dotNetRef = DotNetObjectReference.Create(LoggingBridge);
|
||||
await JSRuntime.InvokeVoidAsync("NexusLogging.initializeBridge", dotNetRef);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[SerilogBridge] Failed to initialize Blazor/JS Bridge: {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user