feat: implement real-time reading progress and refactor profile to CQRS
This commit is contained in:
@@ -36,6 +36,13 @@ This skill defines the architectural guardrails for the NexusReader project to e
|
||||
+- 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.
|
||||
|
||||
### 6. 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.New`
|
||||
- `dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web.New`
|
||||
- Ensure the migration is applied to all local development environments before proceeding with feature verification.
|
||||
|
||||
## Audit Scripts
|
||||
- [ArchCheck.sh](scripts/arch_check.sh): A shell script to scan for illegal cross-layer imports.
|
||||
|
||||
|
||||
@@ -3,4 +3,10 @@ using MediatR;
|
||||
|
||||
namespace NexusReader.Application.Commands.Sync;
|
||||
|
||||
public record UpdateReadingProgressCommand(string PageId, string UserId, string? ExcludedConnectionId = null) : IRequest<Result>;
|
||||
public record UpdateReadingProgressCommand(
|
||||
string PageId,
|
||||
string UserId,
|
||||
Guid EbookId,
|
||||
double Progress,
|
||||
string? ChapterTitle,
|
||||
string? ExcludedConnectionId = null) : IRequest<Result>;
|
||||
|
||||
@@ -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,60 @@
|
||||
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 AppDbContext _dbContext;
|
||||
|
||||
public GetUserProfileQueryHandler(AppDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken 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..."
|
||||
}).FirstOrDefault()
|
||||
})
|
||||
.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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,10 +183,8 @@ 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");
|
||||
@@ -177,9 +193,16 @@ namespace NexusReader.Data.Migrations
|
||||
.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)
|
||||
@@ -196,11 +219,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 =>
|
||||
@@ -246,7 +271,7 @@ namespace NexusReader.Data.Migrations
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits", (string)null);
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
@@ -278,7 +303,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 +438,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 +483,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 +518,7 @@ namespace NexusReader.Data.Migrations
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans", (string)null);
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
@@ -587,12 +612,20 @@ 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");
|
||||
});
|
||||
|
||||
@@ -637,6 +670,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");
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -34,6 +34,11 @@ public class Ebook
|
||||
|
||||
public DateTime? LastReadDate { get; set; }
|
||||
|
||||
public double Progress { get; set; } = 0;
|
||||
|
||||
[MaxLength(255)]
|
||||
public string? LastChapter { get; set; }
|
||||
|
||||
// Relationship to NexusUser
|
||||
[Required]
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
@@ -35,6 +35,15 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReading
|
||||
user.LastReadPageId = request.PageId;
|
||||
user.LastReadAt = now;
|
||||
|
||||
// Update specific Ebook progress
|
||||
var ebook = await context.Ebooks.FirstOrDefaultAsync(e => e.Id == request.EbookId, cancellationToken);
|
||||
if (ebook != null)
|
||||
{
|
||||
ebook.Progress = request.Progress;
|
||||
ebook.LastChapter = request.ChapterTitle;
|
||||
ebook.LastReadDate = now;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Broadcast to other devices
|
||||
|
||||
@@ -15,12 +15,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)
|
||||
{
|
||||
var userId = Context.UserIdentifier;
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, Context.ConnectionId));
|
||||
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, ebookId, progress, chapterTitle, Context.ConnectionId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,23 @@ using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Queries.Reader;
|
||||
using VersOne.Epub;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
public class EpubService : IEpubService
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private const string EpubPath = "wwwroot/assets/book.epub";
|
||||
private const int WordThreshold = 1000;
|
||||
|
||||
public EpubService(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex)
|
||||
{
|
||||
try
|
||||
@@ -100,7 +109,14 @@ public class EpubService : IEpubService
|
||||
blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}"));
|
||||
}
|
||||
|
||||
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle));
|
||||
// Find the EbookId from DB for this file
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync();
|
||||
var ebookId = await context.Ebooks
|
||||
.Where(e => e.FilePath.Contains("book.epub"))
|
||||
.Select(e => e.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle, ebookId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -118,8 +118,18 @@
|
||||
{
|
||||
await Coordinator.OnBlockReachedAsync(blockId, content);
|
||||
|
||||
// Debounce sync update (simple version: every 5 seconds or on a timer)
|
||||
await SyncService.UpdateProgressAsync(blockId);
|
||||
if (ViewModel != null)
|
||||
{
|
||||
// Calculate progress: (CurrentChapter / TotalChapters) * 100
|
||||
// Simple approximation for now: chapter-based
|
||||
double progress = ((double)(ViewModel.CurrentChapterIndex + 1) / ViewModel.TotalChapters) * 100;
|
||||
|
||||
await SyncService.UpdateProgressAsync(
|
||||
blockId,
|
||||
ViewModel.EbookId,
|
||||
progress,
|
||||
ViewModel.ChapterTitle);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp)
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace NexusReader.UI.Shared.Services;
|
||||
public interface ISyncService
|
||||
{
|
||||
Task<Result> InitializeAsync();
|
||||
Task<Result> UpdateProgressAsync(string pageId);
|
||||
Task<Result> UpdateProgressAsync(string pageId, Guid ebookId, double progress, string? chapterTitle);
|
||||
event Func<string, DateTime, Task> OnProgressReceived;
|
||||
Task DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
|
||||
_hubConnection.On<string, DateTime>("ProgressUpdated", async (pageId, timestamp) =>
|
||||
{
|
||||
// Note: In the future we might want to receive ebookId and progress here too
|
||||
if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp);
|
||||
});
|
||||
|
||||
@@ -63,7 +64,7 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
|
||||
private string? _lastSentPageId;
|
||||
|
||||
public async Task<Result> UpdateProgressAsync(string pageId)
|
||||
public async Task<Result> UpdateProgressAsync(string pageId, Guid ebookId, double progress, string? chapterTitle)
|
||||
{
|
||||
if (pageId == _lastSentPageId) return Result.Ok();
|
||||
|
||||
@@ -82,7 +83,7 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
|
||||
if (_hubConnection?.State == HubConnectionState.Connected)
|
||||
{
|
||||
await _hubConnection.SendAsync("UpdateProgress", pageId, token);
|
||||
await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, token);
|
||||
_lastSentPageId = pageId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ using NexusReader.Web.Components;
|
||||
using NexusReader.Application;
|
||||
using NexusReader.Infrastructure;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Application.Queries.User;
|
||||
using MediatR;
|
||||
using NexusReader.Web.Client.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using NexusReader.Domain.Entities;
|
||||
@@ -430,49 +431,15 @@ app.MapGet("/identity/callback/google", async (
|
||||
return Results.Redirect("/account/login?error=ProvisioningFailed");
|
||||
});
|
||||
|
||||
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager, IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||
app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userId == null) return Results.Unauthorized();
|
||||
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
var result = await mediator.Send(new GetUserProfileQuery(userId));
|
||||
if (result.IsFailed) return Results.NotFound(result.Errors.FirstOrDefault()?.Message);
|
||||
|
||||
var profile = await dbContext.Users
|
||||
.Where(u => u.Id == 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 = 65,
|
||||
LastChapter = "Chapter 4: Renaissance in Italy"
|
||||
}).FirstOrDefault()
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (profile == null) return Results.NotFound();
|
||||
|
||||
return Results.Ok(profile);
|
||||
return Results.Ok(result.Value);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
|
||||
@@ -5,6 +5,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Application.Queries.User;
|
||||
using MediatR;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
|
||||
namespace NexusReader.Web.New.Services;
|
||||
@@ -13,18 +15,18 @@ public class ServerIdentityService : IIdentityService
|
||||
{
|
||||
private readonly UserManager<NexusUser> _userManager;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public event Func<Task>? OnStateInvalidated;
|
||||
|
||||
public ServerIdentityService(
|
||||
UserManager<NexusUser> userManager,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
IMediator mediator)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
|
||||
@@ -46,40 +48,21 @@ public class ServerIdentityService : IIdentityService
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userId == null) return Result.Fail("User ID not found.");
|
||||
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var result = await _mediator.Send(new GetUserProfileQuery(userId));
|
||||
if (result.IsFailed) return Result.Fail(result.Errors);
|
||||
|
||||
var profile = await dbContext.Users
|
||||
.Where(u => u.Id == userId)
|
||||
.Select(u => new UserProfile(
|
||||
u.Email ?? string.Empty,
|
||||
u.AITokensUsed,
|
||||
u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty,
|
||||
u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
||||
{
|
||||
Id = u.SubscriptionPlan.Id,
|
||||
Name = u.SubscriptionPlan.PlanName,
|
||||
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
|
||||
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
|
||||
} : new SubscriptionPlanDto(),
|
||||
u.QuizResults.Any(q => q.TotalQuestions > 0)
|
||||
? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100)
|
||||
: 0,
|
||||
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 = 65, // Hardcoded for now as per design requirements, will link to real segments later
|
||||
LastChapter = "Chapter 4: Renaissance in Italy"
|
||||
}).FirstOrDefault()
|
||||
))
|
||||
.FirstOrDefaultAsync();
|
||||
var dto = result.Value;
|
||||
|
||||
return profile != null ? Result.Ok(profile) : Result.Fail("Profile not found.");
|
||||
// Map DTO to UI record
|
||||
var profile = new UserProfile(
|
||||
dto.Email,
|
||||
dto.AITokensUsed,
|
||||
dto.TenantId,
|
||||
dto.Plan,
|
||||
dto.AverageQuizScore,
|
||||
dto.LastReadBook
|
||||
);
|
||||
|
||||
return Result.Ok(profile);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user