feat: Ingestion Pipeline Stabilization and WASM Service Proxies (#42)

This PR stabilizes the Nexus Ingestion Engine by implementing functional service proxies for the Blazor WASM client and refining the backend infrastructure for real-time progress tracking and database compatibility.

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

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

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #42
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #42.
This commit is contained in:
2026-05-13 18:24:24 +00:00
committed by Marek Jaisński
parent d5c2952bec
commit 5a2223a4c8
39 changed files with 6134 additions and 301 deletions
@@ -0,0 +1,38 @@
namespace NexusReader.Application.Abstractions.Messaging;
/// <summary>
/// Abstraction for broadcasting real-time sync events to connected clients.
/// Defined in Application to prevent a direct dependency on SignalR in Application layer handlers.
/// </summary>
public interface ISyncBroadcaster
{
/// <summary>
/// Broadcasts a reading progress update to all devices belonging to the specified user,
/// optionally excluding the originating connection.
/// </summary>
/// <param name="userId">The user whose other devices should be notified.</param>
/// <param name="pageId">The block/page ID the user has reached.</param>
/// <param name="timestamp">The server-side UTC timestamp of the update.</param>
/// <param name="excludedConnectionId">SignalR connection ID to exclude (the sender's device).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task BroadcastProgressAsync(
string userId,
string pageId,
DateTime timestamp,
string? excludedConnectionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Broadcasts real-time ingestion status updates to a specific user.
/// This is used by background workers to provide feedback during AI-intensive processing.
/// </summary>
/// <param name="userId">The ID of the user who owns the ingestion request.</param>
/// <param name="message">A human-readable status message (e.g., "Parsing chapters...").</param>
/// <param name="progress">Progress percentage (0.0 to 1.0).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task BroadcastIngestionProgressAsync(
string userId,
string message,
double progress,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,30 @@
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Abstractions.Persistence;
/// <summary>
/// Abstraction for Ebook and Author persistence operations.
/// Defined in the Application layer to avoid a direct dependency on EF Core.
/// </summary>
public interface IEbookRepository
{
/// <summary>
/// Finds an author by name using a case-insensitive comparison.
/// </summary>
Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new author to the repository (staged, not yet persisted).
/// </summary>
void AddAuthor(Author author);
/// <summary>
/// Adds a new ebook to the repository (staged, not yet persisted).
/// </summary>
void AddEbook(Ebook ebook);
/// <summary>
/// Persists all staged changes to the underlying store.
/// </summary>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -3,7 +3,21 @@ using NexusReader.Application.Queries.Reader;
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// Reads and parses EPUB content for a specific ebook and chapter.
/// </summary>
public interface IEpubReader
{
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex, string? userId = null);
/// <summary>
/// Retrieves the content blocks for a given chapter of the specified ebook.
/// </summary>
/// <param name="ebookId">The unique ID of the ebook to read.</param>
/// <param name="chapterIndex">Zero-based chapter index.</param>
/// <param name="userId">The authenticated user's ID (used for tenant isolation in the DB lookup).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(
Guid ebookId,
int chapterIndex,
string? userId = null,
CancellationToken cancellationToken = default);
}
@@ -1,30 +1,27 @@
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Commands.Library;
public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Result<Guid>>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly IEbookRepository _ebookRepository;
private readonly IBookStorageService _storageService;
public IngestEbookCommandHandler(
IDbContextFactory<AppDbContext> dbContextFactory,
IEbookRepository ebookRepository,
IBookStorageService storageService)
{
_dbContextFactory = dbContextFactory;
_ebookRepository = ebookRepository;
_storageService = storageService;
}
public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
{
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
string epubPath;
string? coverUrl;
@@ -36,6 +33,10 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
? await _storageService.SaveCoverAsync(request.CoverImage, $"{request.Title}_cover.jpg")
: null;
}
catch (IOException ex)
{
return Result.Fail(new Error($"Storage I/O failure: {ex.Message}").CausedBy(ex));
}
catch (Exception ex)
{
return Result.Fail(new Error($"Storage failure: {ex.Message}").CausedBy(ex));
@@ -43,17 +44,16 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
try
{
// 2. Resolve Author
var authorName = string.IsNullOrWhiteSpace(request.AuthorName) ? "Unknown Author" : request.AuthorName.Trim();
// Use case-insensitive comparison
var author = await context.Authors
.FirstOrDefaultAsync(a => a.Name.ToLower() == authorName.ToLower(), cancellationToken);
// 2. Resolve Author (case-insensitive via repository)
var authorName = string.IsNullOrWhiteSpace(request.AuthorName)
? "Unknown Author"
: request.AuthorName.Trim();
var author = await _ebookRepository.FindAuthorByNameAsync(authorName, cancellationToken);
if (author == null)
{
author = new Author { Name = authorName };
context.Authors.Add(author);
_ebookRepository.AddAuthor(author);
}
// 3. Create Ebook
@@ -61,25 +61,21 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
{
Title = request.Title,
Author = author,
FilePath = epubPath, // Relative URL from wwwroot
FilePath = epubPath,
CoverUrl = coverUrl,
UserId = request.UserId,
TenantId = request.TenantId,
AddedDate = DateTime.UtcNow
};
context.Ebooks.Add(ebook);
await context.SaveChangesAsync(cancellationToken);
_ebookRepository.AddEbook(ebook);
await _ebookRepository.SaveChangesAsync(cancellationToken);
return Result.Ok(ebook.Id);
}
catch (DbUpdateException ex)
catch (Exception ex)
{
return Result.Fail(new Error($"Database error during ingestion: {ex.Message}").CausedBy(ex));
}
catch (Exception ex)
{
return Result.Fail(new Error($"Unexpected error during ingestion: {ex.Message}").CausedBy(ex));
}
}
}
@@ -0,0 +1,61 @@
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Commands.Sync;
/// <summary>
/// Handles the <see cref="UpdateReadingProgressCommand"/>.
/// Persists the user's reading position and broadcasts the update to other connected devices.
/// </summary>
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly ISyncBroadcaster _broadcaster;
public UpdateReadingProgressCommandHandler(
IDbContextFactory<AppDbContext> dbContextFactory,
ISyncBroadcaster broadcaster)
{
_dbContextFactory = dbContextFactory;
_broadcaster = broadcaster;
}
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
{
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
return Result.Fail("User not found.");
}
var now = DateTime.UtcNow;
user.LastReadPageId = request.PageId;
user.LastReadAt = now;
var ebook = await context.Ebooks.FirstOrDefaultAsync(e => e.Id == request.EbookId, cancellationToken);
if (ebook != null)
{
ebook.Progress = request.Progress;
ebook.LastChapter = request.ChapterTitle;
ebook.LastChapterIndex = request.ChapterIndex;
ebook.LastReadDate = now;
}
await context.SaveChangesAsync(cancellationToken);
// Broadcast to other devices via the abstracted broadcaster
await _broadcaster.BroadcastProgressAsync(
request.UserId,
request.PageId,
now,
request.ExcludedConnectionId,
cancellationToken);
return Result.Ok();
}
}
@@ -2,4 +2,13 @@ using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Reader;
public record GetReaderPageQuery(int ChapterIndex = 0, string? UserId = null) : IQuery<ReaderPageViewModel>;
/// <summary>
/// Query to retrieve a specific chapter of a user's ebook.
/// </summary>
/// <param name="EbookId">The ID of the ebook to read.</param>
/// <param name="ChapterIndex">Zero-based chapter index.</param>
/// <param name="UserId">The authenticated user's ID for tenant isolation.</param>
public record GetReaderPageQuery(
Guid EbookId,
int ChapterIndex = 0,
string? UserId = null) : IQuery<ReaderPageViewModel>;
@@ -6,15 +6,15 @@ namespace NexusReader.Application.Queries.Reader;
internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel>
{
private readonly IEpubReader _epubService;
private readonly IEpubReader _epubReader;
public GetReaderPageQueryHandler(IEpubReader epubService)
public GetReaderPageQueryHandler(IEpubReader epubReader)
{
_epubService = epubService;
_epubReader = epubReader;
}
public async Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
public Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
{
return await _epubService.GetEpubContentAsync(request.ChapterIndex, request.UserId);
return _epubReader.GetEpubContentAsync(request.EbookId, request.ChapterIndex, request.UserId, cancellationToken);
}
}