refactor(arch): introduce IEbookRepository, ISyncBroadcaster, fix EpubReader path resolution

Critical fixes (review findings #1, #2, #3):
- Create IEbookRepository abstraction in Application layer
- Remove illegal EF Core dependency from IngestEbookCommandHandler
- Create EbookRepository implementation in Infrastructure/Persistence
- Create ISyncBroadcaster in Application/Abstractions/Messaging
- Create SignalRSyncBroadcaster in Infrastructure/RealTime
- Move UpdateReadingProgressCommandHandler from Infrastructure → Application
- Add EbookId to GetReaderPageQuery and IEpubReader signature
- Rewrite EpubReaderService: DB-resolved file path, remove auto-provisioning
- Split EpubService.cs into EpubReaderService.cs + EpubMetadataExtractor.cs
- Add CurrentEbookId to IReaderNavigationService and ReaderNavigationService
- Update WasmEpubReader and /api/epub endpoint for new signature

High severity fixes (#4, #6, #7, #8, #16):
- Change BookStorageService registration from Singleton → Scoped
- Fix empty catch{} in ReaderCanvas JS interop init — now logs warnings
- Replace all Console.WriteLine with ILogger in KnowledgeService + ReaderCanvas
- Cache JsonSerializerOptions as static field in KnowledgeService
- Wrap SyncService Task.Run body in comprehensive try/catch with ILogger

Medium/Low fixes (#11, #13, #14, #15, #18, #20):
- BookIngestionModal.DisposeAsync now nullifies _epubBytes (50MB array)
- KnowledgeCoordinator.OnGraphUpdated: Action<T> → Func<T, Task>
- BookStorageService: Path.Combine → forward-slash string interpolation
- SignalR CancellationToken passed as named parameter (not payload arg)
This commit is contained in:
2026-05-12 21:21:30 +02:00
parent d5c2952bec
commit 150cbcdc29
34 changed files with 5321 additions and 300 deletions
@@ -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));
}
}
}