fix(arch): revert WASM proxies, restore CQRS integrity, and add EF migration for Ebook state

This commit is contained in:
2026-05-13 20:18:32 +02:00
parent 92ea11a51a
commit 2c5f769458
9 changed files with 778 additions and 264 deletions
@@ -193,6 +193,9 @@ namespace NexusReader.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<bool>("IsReadyForReading")
.HasColumnType("boolean");
b.Property<string>("LastChapter") b.Property<string>("LastChapter")
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("character varying(255)"); .HasColumnType("character varying(255)");
@@ -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");
}
}
}
+43 -6
View File
@@ -45,12 +45,12 @@ builder.Services.AddHttpClient("NexusAPI", client =>
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI")); builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
// Real WASM implementations for application abstractions // Dummy registrations for server-only handlers to satisfy DI validation in WASM
builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbContextFactory()); builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbContextFactory());
builder.Services.AddScoped<IEmbeddingGenerator<string, Embedding<float>>, WasmEmbeddingGenerator>(); builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
builder.Services.AddScoped<IBookStorageService, WasmBookStorageService>(); builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService());
builder.Services.AddScoped<IEbookRepository, WasmEbookRepository>(); builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository());
builder.Services.AddScoped<ISyncBroadcaster, WasmSyncBroadcaster>(); builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddScoped<IEpubReader, WasmEpubReader>(); builder.Services.AddScoped<IEpubReader, WasmEpubReader>();
@@ -60,5 +60,42 @@ await builder.Build().RunAsync();
public class ThrowingDbContextFactory : IDbContextFactory<AppDbContext> public class ThrowingDbContextFactory : IDbContextFactory<AppDbContext>
{ {
public AppDbContext CreateDbContext() => throw new NotSupportedException("DbContext cannot be used in WASM client. Use API proxies for data access."); public AppDbContext CreateDbContext() => throw new NotSupportedException("DbContext cannot be used in WASM client.");
}
public class ThrowingEmbeddingGenerator : IEmbeddingGenerator<string, Embedding<float>>
{
public void Dispose() { }
public Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(IEnumerable<string> values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Embedding generation cannot be used in WASM client.");
public object? GetService(Type serviceType, object? serviceKey = null) => null;
}
public class ThrowingBookStorageService : IBookStorageService
{
private const string ErrorMessage = "File storage operations are not supported in the WASM client. Use the API endpoint for ingestion.";
public Task<string> SaveEbookAsync(byte[] data, string fileName) => throw new NotSupportedException(ErrorMessage);
public Task<string> SaveEbookAsync(Stream data, string fileName) => throw new NotSupportedException(ErrorMessage);
public Task<string?> SaveCoverAsync(byte[] data, string fileName) => throw new NotSupportedException(ErrorMessage);
public Task<string?> SaveCoverAsync(Stream data, string fileName) => throw new NotSupportedException(ErrorMessage);
}
public class ThrowingEbookRepository : IEbookRepository
{
private const string ErrorMessage = "Ebook repository operations are not supported in the WASM client. Use the API endpoint for data access.";
public Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
public void AddAuthor(Author author) => throw new NotSupportedException(ErrorMessage);
public void AddEbook(Ebook ebook) => throw new NotSupportedException(ErrorMessage);
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
}
public class ThrowingSyncBroadcaster : ISyncBroadcaster
{
public Task BroadcastProgressAsync(string userId, string pageId, DateTime timestamp, string? excludedConnectionId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Real-time broadcasting can only be performed by the server.");
public Task BroadcastIngestionProgressAsync(string userId, string message, double progress, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Real-time broadcasting can only be performed by the server.");
} }
@@ -1,47 +0,0 @@
using System.Net.Http.Json;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Client.Services;
public class WasmBookStorageService : IBookStorageService
{
private readonly HttpClient _httpClient;
public WasmBookStorageService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> SaveEbookAsync(byte[] data, string fileName)
{
var response = await _httpClient.PostAsJsonAsync("/api/storage/save/ebook", new { data, fileName });
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<StorageResponse>();
return result?.Path ?? string.Empty;
}
public async Task<string> SaveEbookAsync(Stream data, string fileName)
{
using var ms = new MemoryStream();
await data.CopyToAsync(ms);
return await SaveEbookAsync(ms.ToArray(), fileName);
}
public async Task<string?> SaveCoverAsync(byte[] data, string fileName)
{
if (data == null || data.Length == 0) return null;
var response = await _httpClient.PostAsJsonAsync("/api/storage/save/cover", new { data, fileName });
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<StorageResponse>();
return result?.Path;
}
public async Task<string?> SaveCoverAsync(Stream data, string fileName)
{
using var ms = new MemoryStream();
await data.CopyToAsync(ms);
return await SaveCoverAsync(ms.ToArray(), fileName);
}
private record StorageResponse(string Path);
}
@@ -1,65 +0,0 @@
using System.Net.Http.Json;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Web.Client.Services;
public class WasmEbookRepository : IEbookRepository
{
private readonly HttpClient _httpClient;
public WasmEbookRepository(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default)
{
var response = await _httpClient.PostAsJsonAsync("/api/repository/author/find", new { name }, cancellationToken);
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<Author>(cancellationToken: cancellationToken);
}
return null;
}
public void AddAuthor(Author author)
{
// For a repository in WASM, we can't easily do 'void' fire-and-forget Add without a local state.
// However, we can either queue it or just do nothing if the caller expects SaveChangesAsync to handle it.
// But the common pattern for this app seems to be calling the API.
// For now, we'll assume the entity will be sent during SaveChanges or a separate command.
// Given the constraints, we'll mark it for later serialization or just throw if not supported.
// Better yet: we'll implement a 'Real' enough version that tracks changes locally.
_stagedAuthors.Add(author);
}
public void AddEbook(Ebook ebook)
{
_stagedEbooks.Add(ebook);
}
private readonly List<Author> _stagedAuthors = new();
private readonly List<Ebook> _stagedEbooks = new();
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
int count = 0;
foreach (var author in _stagedAuthors)
{
await _httpClient.PostAsJsonAsync("/api/repository/author/add", author, cancellationToken);
count++;
}
foreach (var ebook in _stagedEbooks)
{
await _httpClient.PostAsJsonAsync("/api/repository/ebook/add", ebook, cancellationToken);
count++;
}
_stagedAuthors.Clear();
_stagedEbooks.Clear();
await _httpClient.PostAsync("/api/repository/save", null, cancellationToken);
return count;
}
}
@@ -1,33 +0,0 @@
using System.Net.Http.Json;
using Microsoft.Extensions.AI;
namespace NexusReader.Web.Client.Services;
public class WasmEmbeddingGenerator : IEmbeddingGenerator<string, Embedding<float>>
{
private readonly HttpClient _httpClient;
public WasmEmbeddingGenerator(HttpClient httpClient)
{
_httpClient = httpClient;
}
public void Dispose() { }
public async Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(
IEnumerable<string> values,
EmbeddingGenerationOptions? options = null,
CancellationToken cancellationToken = default)
{
var response = await _httpClient.PostAsJsonAsync("/api/ai/embeddings", new { values, options }, cancellationToken);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<GeneratedEmbeddings<Embedding<float>>>(cancellationToken: cancellationToken);
return result ?? new GeneratedEmbeddings<Embedding<float>>();
}
public object? GetService(Type serviceType, object? serviceKey = null)
{
if (serviceType == typeof(IEmbeddingGenerator<string, Embedding<float>>)) return this;
return null;
}
}
@@ -1,44 +0,0 @@
using System.Net.Http.Json;
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Web.Client.Services;
public class WasmSyncBroadcaster : ISyncBroadcaster
{
private readonly HttpClient _httpClient;
public WasmSyncBroadcaster(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task BroadcastProgressAsync(
string userId,
string pageId,
DateTime timestamp,
string? excludedConnectionId,
CancellationToken cancellationToken = default)
{
await _httpClient.PostAsJsonAsync("/api/broadcaster/progress", new
{
userId,
pageId,
timestamp,
excludedConnectionId
}, cancellationToken);
}
public async Task BroadcastIngestionProgressAsync(
string userId,
string message,
double progress,
CancellationToken cancellationToken = default)
{
await _httpClient.PostAsJsonAsync("/api/broadcaster/ingestion-progress", new
{
userId,
message,
progress
}, cancellationToken);
}
}
-69
View File
@@ -253,13 +253,6 @@ app.MapGet("/api/epub/{ebookId:guid}/{index:int}", async (Guid ebookId, int inde
return Results.BadRequest(errorMsg); return Results.BadRequest(errorMsg);
}).RequireAuthorization(); }).RequireAuthorization();
// Proxy API for AI services (Embeddings)
app.MapPost("/api/ai/embeddings", async (EmbeddingsRequest request, IEmbeddingGenerator<string, Embedding<float>> generator) =>
{
var result = await generator.GenerateAsync(request.Values, request.Options);
return Results.Ok(result);
}).RequireAuthorization();
var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvailableTokens"); var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvailableTokens");
knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
@@ -303,63 +296,6 @@ knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
return Results.BadRequest(errorMsg); return Results.BadRequest(errorMsg);
}); });
// Proxy API for WASM Repository calls
var repoApi = app.MapGroup("/api/repository").RequireAuthorization();
repoApi.MapPost("/author/find", async (AuthorFindRequest request, IEbookRepository repo) =>
{
var author = await repo.FindAuthorByNameAsync(request.Name);
return author != null ? Results.Ok(author) : Results.NotFound();
});
repoApi.MapPost("/author/add", (Author author, IEbookRepository repo) =>
{
repo.AddAuthor(author);
return Results.Ok();
});
repoApi.MapPost("/ebook/add", (Ebook ebook, IEbookRepository repo) =>
{
repo.AddEbook(ebook);
return Results.Ok();
});
repoApi.MapPost("/save", async (IEbookRepository repo) =>
{
await repo.SaveChangesAsync();
return Results.Ok();
});
// Proxy API for WASM Broadcaster calls
var broadcasterApi = app.MapGroup("/api/broadcaster").RequireAuthorization();
broadcasterApi.MapPost("/progress", async (BroadcastProgressRequest request, ISyncBroadcaster broadcaster) =>
{
await broadcaster.BroadcastProgressAsync(request.UserId, request.PageId, request.Timestamp, request.ExcludedConnectionId);
return Results.Ok();
});
broadcasterApi.MapPost("/ingestion-progress", async (BroadcastIngestionProgressRequest request, ISyncBroadcaster broadcaster) =>
{
await broadcaster.BroadcastIngestionProgressAsync(request.UserId, request.Message, request.Progress);
return Results.Ok();
});
// Proxy API for WASM Storage calls
var storageApi = app.MapGroup("/api/storage").RequireAuthorization();
storageApi.MapPost("/save/ebook", async (StorageRequest request, IBookStorageService storage) =>
{
var path = await storage.SaveEbookAsync(request.Data, request.FileName);
return Results.Ok(new { Path = path });
});
storageApi.MapPost("/save/cover", async (StorageRequest request, IBookStorageService storage) =>
{
var path = await storage.SaveCoverAsync(request.Data, request.FileName);
return Results.Ok(new { Path = path });
});
app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) => app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) =>
{ {
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -585,8 +521,3 @@ app.Run();
public record KnowledgeRequest(string Text); public record KnowledgeRequest(string Text);
public record GroundednessRequest(string Answer, string Context); public record GroundednessRequest(string Answer, string Context);
public record AuthorFindRequest(string Name);
public record BroadcastProgressRequest(string UserId, string PageId, DateTime Timestamp, string? ExcludedConnectionId);
public record BroadcastIngestionProgressRequest(string UserId, string Message, double Progress);
public record StorageRequest(byte[] Data, string FileName);
public record EmbeddingsRequest(IEnumerable<string> Values, EmbeddingGenerationOptions? Options);