fix: prevent potential component state updates after disposal and implement dedicated repository for quiz results

This commit is contained in:
2026-05-26 14:14:51 +02:00
parent 381f26ed3e
commit a2aecf7dd3
14 changed files with 323 additions and 132 deletions
@@ -23,6 +23,11 @@ public interface IEbookRepository
/// </summary> /// </summary>
void AddEbook(Ebook ebook); void AddEbook(Ebook ebook);
/// <summary>
/// Finds an ebook by its unique identifier.
/// </summary>
Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Persists all staged changes to the underlying store. /// Persists all staged changes to the underlying store.
/// </summary> /// </summary>
@@ -0,0 +1,25 @@
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Abstractions.Persistence;
/// <summary>
/// Abstraction for QuizResult and related User entity lookup.
/// Defined in the Application layer to maintain Clean Architecture isolation.
/// </summary>
public interface IQuizResultRepository
{
/// <summary>
/// Finds a user by ID to extract tenant context.
/// </summary>
Task<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new quiz result to the database.
/// </summary>
void AddQuizResult(QuizResult quizResult);
/// <summary>
/// Persists all staged changes to the repository.
/// </summary>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -1,6 +1,8 @@
using FluentResults; using FluentResults;
using System.Linq;
using MediatR; using MediatR;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NexusReader.Application.Abstractions.Messaging; using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
@@ -79,15 +81,37 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
// 4. Trigger asynchronous background processing and vector indexing // 4. Trigger asynchronous background processing and vector indexing
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<IngestEbookCommandHandler>>();
var broadcaster = scope.ServiceProvider.GetRequiredService<ISyncBroadcaster>();
try try
{ {
using var scope = _scopeFactory.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>(); var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId)); var result = await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId));
if (result.IsFailed)
{
var errorMsg = string.Join("; ", result.Errors.Select(e => e.Message));
logger.LogError("[IngestEbook] Background ebook processing failed for Ebook {EbookId}: {Error}", ebook.Id, errorMsg);
await broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
$"Błąd indeksowania: {errorMsg}",
1.0);
}
} }
catch (Exception) catch (Exception ex)
{ {
// Swallowed to prevent ThreadPool crashes logger.LogError(ex, "[IngestEbook] Exception during background ebook processing for Ebook {EbookId}", ebook.Id);
try
{
await broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
$"Błąd krytyczny podczas przetwarzania e-booka: {ex.Message}",
1.0);
}
catch
{
// Ignore broadcast failures to prevent crashes
}
} }
}); });
@@ -5,8 +5,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NexusReader.Application.Abstractions.Messaging; using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Commands.Library; namespace NexusReader.Application.Commands.Library;
@@ -18,20 +18,20 @@ public record ProcessEbookCommand(
public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, Result<bool>> public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, Result<bool>>
{ {
private readonly IDbContextFactory<AppDbContext> _dbContextFactory; private readonly IEbookRepository _ebookRepository;
private readonly IKnowledgeService _knowledgeService; private readonly IKnowledgeService _knowledgeService;
private readonly IEpubExtractor _epubExtractor; private readonly IEpubExtractor _epubExtractor;
private readonly ISyncBroadcaster _broadcaster; private readonly ISyncBroadcaster _broadcaster;
private readonly ILogger<ProcessEbookCommandHandler> _logger; private readonly ILogger<ProcessEbookCommandHandler> _logger;
public ProcessEbookCommandHandler( public ProcessEbookCommandHandler(
IDbContextFactory<AppDbContext> dbContextFactory, IEbookRepository ebookRepository,
IKnowledgeService knowledgeService, IKnowledgeService knowledgeService,
IEpubExtractor epubExtractor, IEpubExtractor epubExtractor,
ISyncBroadcaster broadcaster, ISyncBroadcaster broadcaster,
ILogger<ProcessEbookCommandHandler> logger) ILogger<ProcessEbookCommandHandler> logger)
{ {
_dbContextFactory = dbContextFactory; _ebookRepository = ebookRepository;
_knowledgeService = knowledgeService; _knowledgeService = knowledgeService;
_epubExtractor = epubExtractor; _epubExtractor = epubExtractor;
_broadcaster = broadcaster; _broadcaster = broadcaster;
@@ -46,8 +46,7 @@ public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, R
{ {
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken); await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken);
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); var ebook = await _ebookRepository.FindByIdAsync(request.EbookId, cancellationToken);
var ebook = await dbContext.Ebooks.FindAsync(new object[] { request.EbookId }, cancellationToken);
if (ebook == null) if (ebook == null)
{ {
_logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId); _logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId);
@@ -122,7 +121,7 @@ public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, R
// Mark the ebook as ready // Mark the ebook as ready
ebook.IsReadyForReading = true; ebook.IsReadyForReading = true;
await dbContext.SaveChangesAsync(cancellationToken); await _ebookRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title); _logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title);
@@ -1,25 +1,22 @@
using FluentResults; using FluentResults;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging; using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Data.Persistence; using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
namespace NexusReader.Application.Commands.Quiz; namespace NexusReader.Application.Commands.Quiz;
public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizResultCommand> public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizResultCommand>
{ {
private readonly IDbContextFactory<AppDbContext> _dbContextFactory; private readonly IQuizResultRepository _quizResultRepository;
public SubmitQuizResultCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory) public SubmitQuizResultCommandHandler(IQuizResultRepository quizResultRepository)
{ {
_dbContextFactory = dbContextFactory; _quizResultRepository = quizResultRepository;
} }
public async Task<Result> Handle(SubmitQuizResultCommand request, CancellationToken cancellationToken) public async Task<Result> Handle(SubmitQuizResultCommand request, CancellationToken cancellationToken)
{ {
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); var user = await _quizResultRepository.FindUserByIdAsync(request.UserId, cancellationToken);
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null) if (user == null)
{ {
return Result.Fail("User not found."); return Result.Fail("User not found.");
@@ -36,8 +33,8 @@ public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizR
CompletedDate = DateTime.UtcNow CompletedDate = DateTime.UtcNow
}; };
context.QuizResults.Add(quizResult); _quizResultRepository.AddQuizResult(quizResult);
await context.SaveChangesAsync(cancellationToken); await _quizResultRepository.SaveChangesAsync(cancellationToken);
return Result.Ok(); return Result.Ok();
} }
@@ -18,14 +18,15 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
{ {
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var profile = await dbContext.Users
var userRaw = await dbContext.Users
.Where(u => u.Id == request.UserId) .Where(u => u.Id == request.UserId)
.Select(u => new UserProfileDto .Select(u => new
{ {
Email = u.Email ?? string.Empty, Email = u.Email ?? string.Empty,
UserId = u.Id, UserId = u.Id,
AITokensUsed = u.AITokensUsed, AITokensUsed = u.AITokensUsed,
TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty, TenantIdString = u.TenantId,
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
{ {
Id = u.SubscriptionPlan.Id, Id = u.SubscriptionPlan.Id,
@@ -33,12 +34,17 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
AITokenLimit = u.SubscriptionPlan.AITokenLimit, AITokenLimit = u.SubscriptionPlan.AITokenLimit,
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
} : new SubscriptionPlanDto(), } : new SubscriptionPlanDto(),
AverageQuizScore = u.QuizResults.Any(q => q.TotalQuestions > 0) QuizResults = u.QuizResults.Select(q => new
? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100) {
: 0, q.Score,
q.TotalQuestions,
q.Id,
q.Topic,
q.Percentage,
q.CompletedDate
}).ToList(),
DisplayName = u.DisplayName, DisplayName = u.DisplayName,
BooksReadCount = u.Ebooks.Count(), BooksReadCount = u.Ebooks.Count(),
ConceptsMappedCount = dbContext.KnowledgeUnits.Count(k => k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)),
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
{ {
Id = e.Id, Id = e.Id,
@@ -55,26 +61,6 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
Description = e.Description, Description = e.Description,
IsReadyForReading = e.IsReadyForReading IsReadyForReading = e.IsReadyForReading
}).FirstOrDefault(), }).FirstOrDefault(),
RecentQuizzes = u.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
{
Id = q.Id,
Topic = q.Topic,
Score = q.Score,
TotalQuestions = q.TotalQuestions,
Percentage = q.Percentage,
CompletedDate = q.CompletedDate
}).ToList(),
MappedConcepts = dbContext.KnowledgeUnits
.Where(k => k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId))
.OrderByDescending(k => k.CreatedAt)
.Take(6)
.Select(k => new MappedConceptDto
{
Id = k.Id,
Type = k.Type.ToString(),
Content = k.Content
})
.ToList(),
Roles = dbContext.UserRoles Roles = dbContext.UserRoles
.Where(ur => ur.UserId == u.Id) .Where(ur => ur.UserId == u.Id)
.Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!) .Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!)
@@ -82,11 +68,59 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
}) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (profile == null) if (userRaw == null)
{ {
return Result.Fail("Profile not found."); return Result.Fail("Profile not found.");
} }
var tenantId = userRaw.TenantIdString;
var mappedConcepts = await dbContext.KnowledgeUnits
.Where(k => k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId))
.OrderByDescending(k => k.CreatedAt)
.Take(6)
.Select(k => new MappedConceptDto
{
Id = k.Id,
Type = k.Type.ToString(),
Content = k.Content
})
.ToListAsync(cancellationToken);
var conceptsMappedCount = await dbContext.KnowledgeUnits
.CountAsync(k => k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId), cancellationToken);
int averageQuizScore = 0;
var validQuizzes = userRaw.QuizResults.Where(q => q.TotalQuestions > 0).ToList();
if (validQuizzes.Count > 0)
{
averageQuizScore = (int)(validQuizzes.Average(q => (double)q.Score / q.TotalQuestions) * 100);
}
var profile = new UserProfileDto
{
Email = userRaw.Email,
UserId = userRaw.UserId,
AITokensUsed = userRaw.AITokensUsed,
TenantId = userRaw.TenantIdString != null && userRaw.TenantIdString.Length == 36 ? new Guid(userRaw.TenantIdString) : Guid.Empty,
Plan = userRaw.Plan,
AverageQuizScore = averageQuizScore,
DisplayName = userRaw.DisplayName,
BooksReadCount = userRaw.BooksReadCount,
ConceptsMappedCount = conceptsMappedCount,
LastReadBook = userRaw.LastReadBook,
RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
{
Id = q.Id,
Topic = q.Topic,
Score = q.Score,
TotalQuestions = q.TotalQuestions,
Percentage = q.Percentage,
CompletedDate = q.CompletedDate
}).ToList(),
MappedConcepts = mappedConcepts,
Roles = userRaw.Roles
};
return Result.Ok(profile); return Result.Ok(profile);
} }
} }
@@ -120,6 +120,7 @@ public static class DependencyInjection
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime) // Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
services.AddScoped<IEbookRepository, EbookRepository>(); services.AddScoped<IEbookRepository, EbookRepository>();
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper) // Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>(); services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
@@ -46,6 +46,12 @@ internal sealed class EbookRepository : IEbookRepository
_context.Ebooks.Add(ebook); _context.Ebooks.Add(ebook);
} }
/// <inheritdoc />
public async Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Ebooks.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc /> /// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> _context.SaveChangesAsync(cancellationToken); => _context.SaveChangesAsync(cancellationToken);
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Infrastructure.Persistence;
/// <summary>
/// EF Core implementation of <see cref="IQuizResultRepository"/>.
/// </summary>
internal sealed class QuizResultRepository : IQuizResultRepository
{
private readonly AppDbContext _context;
public QuizResultRepository(AppDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default)
{
return await _context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
}
/// <inheritdoc />
public void AddQuizResult(QuizResult quizResult)
{
_context.QuizResults.Add(quizResult);
}
/// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return _context.SaveChangesAsync(cancellationToken);
}
}
@@ -35,6 +35,7 @@ public class KnowledgeService : IKnowledgeService
private readonly IDriver _neo4jDriver; private readonly IDriver _neo4jDriver;
private const string PromptVersion = "1.7"; private const string PromptVersion = "1.7";
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new(); private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
public KnowledgeService( public KnowledgeService(
IChatClient chatClient, IChatClient chatClient,
@@ -454,6 +455,7 @@ public class KnowledgeService : IKnowledgeService
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default) private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
{ {
await _collectionSemaphore.WaitAsync(cancellationToken);
try try
{ {
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken); var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
@@ -474,7 +476,19 @@ public class KnowledgeService : IKnowledgeService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName); if (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase) ||
(ex.InnerException != null && ex.InnerException.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)))
{
_logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' was already created by another thread.", collectionName);
}
else
{
_logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName);
}
}
finally
{
_collectionSemaphore.Release();
} }
} }
@@ -575,8 +589,9 @@ public class KnowledgeService : IKnowledgeService
); );
searchResult = response.ToList(); searchResult = response.ToList();
} }
catch (Exception) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[KnowledgeService] Qdrant search failed during GetRelevantContextAsync. Returning empty search results.");
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>(); searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
} }
@@ -594,7 +609,10 @@ public class KnowledgeService : IKnowledgeService
summary = sumObj?.ToString(); summary = sumObj?.ToString();
} }
} }
catch {} catch (JsonException ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in RelevantContext mapping.");
}
} }
var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}"; var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}";
return new RelevantContext return new RelevantContext
@@ -747,7 +765,10 @@ public class KnowledgeService : IKnowledgeService
{ {
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue); metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
} }
catch {} catch (JsonException ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in search library mapping.");
}
} }
var dto = new SemanticSearchResultDto var dto = new SemanticSearchResultDto
@@ -871,6 +892,8 @@ public class KnowledgeService : IKnowledgeService
{ {
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var units = await dbContext.KnowledgeUnits var units = await dbContext.KnowledgeUnits
.Include(u => u.Ebook)
.ThenInclude(e => e.Author)
.Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId)) .Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u); guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u);
@@ -916,7 +939,10 @@ public class KnowledgeService : IKnowledgeService
summary = sumObj?.ToString(); summary = sumObj?.ToString();
} }
} }
catch { } catch (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync source hydration.", sourceUnit.Id);
}
} }
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}"; sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
} }
@@ -954,7 +980,10 @@ public class KnowledgeService : IKnowledgeService
summary = sumObj?.ToString(); summary = sumObj?.ToString();
} }
} }
catch { } catch (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync target hydration.", targetUnit.Id);
}
} }
targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}"; targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}";
} }
@@ -986,7 +1015,10 @@ public class KnowledgeService : IKnowledgeService
summary = sumObj?.ToString(); summary = sumObj?.ToString();
} }
} }
catch { } catch (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in fallback AskQuestionAsync.", sourceUnit.Id);
}
} }
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}"; sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
} }
@@ -1082,19 +1114,6 @@ public class KnowledgeService : IKnowledgeService
{ {
citation.Author = unit.Ebook.Author.Name; citation.Author = unit.Ebook.Author.Name;
} }
else if (unit.EbookId.HasValue)
{
try
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var eb = await dbContext.Ebooks.Include(e => e.Author).FirstOrDefaultAsync(e => e.Id == unit.EbookId.Value, cancellationToken);
if (eb?.Author != null)
{
citation.Author = eb.Author.Name;
}
}
catch { }
}
if (!string.IsNullOrEmpty(unit.MetadataJson)) if (!string.IsNullOrEmpty(unit.MetadataJson))
{ {
@@ -1106,7 +1125,10 @@ public class KnowledgeService : IKnowledgeService
citation.PageNumber = pageVal; citation.PageNumber = pageVal;
} }
} }
catch { } catch (JsonException ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync citation mapping.", unit.Id);
}
} }
} }
} }
@@ -1,7 +1,9 @@
@namespace NexusReader.UI.Shared.Components.Atoms @namespace NexusReader.UI.Shared.Components.Atoms
@using System.Text.RegularExpressions @using System.Text.RegularExpressions
@using MediatR
@using NexusReader.Application.DTOs.AI @using NexusReader.Application.DTOs.AI
@inject IKnowledgeService KnowledgeService @using NexusReader.Application.Queries.Library
@inject IMediator Mediator
@inject IReaderNavigationService NavService @inject IReaderNavigationService NavService
@inject IReaderInteractionService InteractionService @inject IReaderInteractionService InteractionService
@inject NavigationManager NavManager @inject NavigationManager NavManager
@@ -100,6 +102,7 @@
private bool _isLoading; private bool _isLoading;
private string? _searchError; private string? _searchError;
private bool _isDropdownOpen; private bool _isDropdownOpen;
private bool _disposed;
private CancellationTokenSource? _searchCts; private CancellationTokenSource? _searchCts;
@@ -140,15 +143,18 @@
{ {
_isLoading = true; _isLoading = true;
_searchError = null; _searchError = null;
await InvokeAsync(StateHasChanged); if (!_disposed)
{
await InvokeAsync(StateHasChanged);
}
try try
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global"; var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
var result = await KnowledgeService.SearchLibrarySemanticallyAsync(SearchValue, tenantId, Limit, token); var result = await Mediator.Send(new SearchLibrarySemanticallyQuery(SearchValue, tenantId, Limit), token);
if (token.IsCancellationRequested) return; if (token.IsCancellationRequested || _disposed) return;
if (result.IsSuccess) if (result.IsSuccess)
{ {
@@ -164,7 +170,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
if (!token.IsCancellationRequested) if (!token.IsCancellationRequested && !_disposed)
{ {
_results.Clear(); _results.Clear();
_searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania."; _searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania.";
@@ -173,7 +179,7 @@
} }
finally finally
{ {
if (!token.IsCancellationRequested) if (!token.IsCancellationRequested && !_disposed)
{ {
_isLoading = false; _isLoading = false;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -291,6 +297,7 @@
IsFocused = false; IsFocused = false;
// Delay slightly to allow click handlers on result cards to execute // Delay slightly to allow click handlers on result cards to execute
await Task.Delay(200); await Task.Delay(200);
if (_disposed) return;
_isDropdownOpen = false; _isDropdownOpen = false;
StateHasChanged(); StateHasChanged();
} }
@@ -305,29 +312,35 @@
private string HighlightQueryWords(string text, string query) private string HighlightQueryWords(string text, string query)
{ {
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(text))
return text; return string.Empty;
var escapedText = System.Net.WebUtility.HtmlEncode(text);
if (string.IsNullOrWhiteSpace(query))
return escapedText;
var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length > 2) .Where(w => w.Length > 2)
.Select(Regex.Escape); .Select(Regex.Escape);
if (!words.Any()) if (!words.Any())
return text; return escapedText;
var pattern = "(" + string.Join("|", words) + ")"; var pattern = "(" + string.Join("|", words) + ")";
try try
{ {
return Regex.Replace(text, pattern, "<mark class=\"search-highlight\">$1</mark>", RegexOptions.IgnoreCase); return Regex.Replace(escapedText, pattern, "<mark class=\"search-highlight\">$1</mark>", RegexOptions.IgnoreCase);
} }
catch catch
{ {
return text; return escapedText;
} }
} }
public void Dispose() public void Dispose()
{ {
_disposed = true;
_searchCts?.Cancel(); _searchCts?.Cancel();
_searchCts?.Dispose(); _searchCts?.Dispose();
} }
@@ -142,6 +142,7 @@
private LocalEpubMetadata? Metadata { get; set; } private LocalEpubMetadata? Metadata { get; set; }
private string? ErrorMessage { get; set; } private string? ErrorMessage { get; set; }
private byte[]? _epubBytes; private byte[]? _epubBytes;
private bool _disposed;
// Allow up to 50 MB // Allow up to 50 MB
private const long MaxFileSize = 50 * 1024 * 1024; private const long MaxFileSize = 50 * 1024 * 1024;
@@ -154,23 +155,30 @@
private async Task HandleIngestionProgress(string message, double progress) private async Task HandleIngestionProgress(string message, double progress)
{ {
if (_disposed) return;
if (!IsIndexing) return; if (!IsIndexing) return;
IngestionStatusMessage = message; IngestionStatusMessage = message;
IngestionProgressPercent = progress; IngestionProgressPercent = progress;
await InvokeAsync(StateHasChanged); if (!_disposed)
{
await InvokeAsync(StateHasChanged);
}
if (progress >= 1.0) if (progress >= 1.0)
{ {
// Give the user a moment to see the completion message // Give the user a moment to see the completion message
await Task.Delay(2500); await Task.Delay(2500);
if (_disposed) return;
// Now close the modal and navigate to the book // Now close the modal and navigate to the book
if (IngestedBookId != Guid.Empty) if (IngestedBookId != Guid.Empty)
{ {
var bookId = IngestedBookId; var bookId = IngestedBookId;
await InvokeAsync(async () => { await InvokeAsync(async () => {
if (_disposed) return;
await CloseModal(); await CloseModal();
ReaderNavigation.NavigateToBook(bookId); ReaderNavigation.NavigateToBook(bookId);
}); });
@@ -227,10 +235,12 @@
using var stream = file.OpenReadStream(MaxFileSize); using var stream = file.OpenReadStream(MaxFileSize);
using var memoryStream = new MemoryStream(); using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream); await stream.CopyToAsync(memoryStream);
if (_disposed) return;
_epubBytes = memoryStream.ToArray(); _epubBytes = memoryStream.ToArray();
memoryStream.Position = 0; memoryStream.Position = 0;
var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream); var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream);
if (_disposed) return;
if (result.IsSuccess) if (result.IsSuccess)
{ {
@@ -245,12 +255,18 @@
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, "Error uploading EPUB"); Logger.LogError(ex, "Error uploading EPUB");
ErrorMessage = $"An unexpected error occurred: {ex.Message}"; if (!_disposed)
{
ErrorMessage = $"An unexpected error occurred: {ex.Message}";
}
} }
finally finally
{ {
IsParsing = false; if (!_disposed)
StateHasChanged(); {
IsParsing = false;
StateHasChanged();
}
} }
} }
@@ -273,10 +289,12 @@
); );
var response = await Http.PostAsJsonAsync("api/library/ingest", request); var response = await Http.PostAsJsonAsync("api/library/ingest", request);
if (_disposed) return;
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var result = await response.Content.ReadFromJsonAsync<IngestResult>(); var result = await response.Content.ReadFromJsonAsync<IngestResult>();
if (_disposed) return;
if (result != null) if (result != null)
{ {
IngestedBookId = result.Id; IngestedBookId = result.Id;
@@ -297,12 +315,18 @@
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, "Error during ingestion"); Logger.LogError(ex, "Error during ingestion");
ErrorMessage = "Failed to save book to library. Please try again."; if (!_disposed)
IsIngesting = false; {
ErrorMessage = "Failed to save book to library. Please try again.";
IsIngesting = false;
}
} }
finally finally
{ {
StateHasChanged(); if (!_disposed)
{
StateHasChanged();
}
} }
} }
@@ -310,6 +334,7 @@
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
_disposed = true;
SyncService.OnIngestionProgressReceived -= HandleIngestionProgress; SyncService.OnIngestionProgressReceived -= HandleIngestionProgress;
// Clear the large byte array so it is eligible for GC even if the component is cached. // Clear the large byte array so it is eligible for GC even if the component is cached.
_epubBytes = null; _epubBytes = null;
+11
View File
@@ -50,6 +50,7 @@ builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbCon
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator()); builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService()); builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService());
builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository()); builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository());
builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepository());
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster()); builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor()); builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor());
@@ -89,6 +90,16 @@ public class ThrowingEbookRepository : IEbookRepository
public Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); public Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
public void AddAuthor(Author author) => throw new NotSupportedException(ErrorMessage); public void AddAuthor(Author author) => throw new NotSupportedException(ErrorMessage);
public void AddEbook(Ebook ebook) => throw new NotSupportedException(ErrorMessage); public void AddEbook(Ebook ebook) => throw new NotSupportedException(ErrorMessage);
public Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
}
public class ThrowingQuizResultRepository : IQuizResultRepository
{
private const string ErrorMessage = "QuizResult repository operations are not supported in the WASM client. Use the API endpoint for data access.";
public Task<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
public void AddQuizResult(QuizResult quizResult) => throw new NotSupportedException(ErrorMessage);
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
} }
@@ -5,53 +5,46 @@ using FluentAssertions;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moq; using Moq;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Commands.Quiz; using NexusReader.Application.Commands.Quiz;
using NexusReader.Data.Persistence; using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Persistence;
using Xunit; using Xunit;
namespace NexusReader.Application.Tests.Commands; namespace NexusReader.Application.Tests.Commands;
public class SubmitQuizResultCommandHandlerTests : IDisposable public class SubmitQuizResultCommandHandlerTests
{ {
private readonly SqliteConnection _connection; private readonly Mock<IQuizResultRepository> _repositoryMock;
private readonly DbContextOptions<AppDbContext> _contextOptions;
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
public SubmitQuizResultCommandHandlerTests() public SubmitQuizResultCommandHandlerTests()
{ {
_connection = new SqliteConnection("DataSource=:memory:"); _repositoryMock = new Mock<IQuizResultRepository>();
_connection.Open();
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
using var context = new AppDbContext(_contextOptions);
context.Database.EnsureCreated();
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new AppDbContext(_contextOptions));
} }
[Fact] [Fact]
public async Task Handle_WithValidRequest_PersistsQuizResultToDatabase() public async Task Handle_WithValidRequest_PersistsQuizResultToDatabase()
{ {
// Arrange // Arrange
using (var context = new AppDbContext(_contextOptions)) var user = new NexusUser
{ {
var user = new NexusUser Id = "user-abc",
{ UserName = "testuser",
Id = "user-abc", Email = "test@example.com",
UserName = "testuser", TenantId = "tenant-xyz",
Email = "test@example.com", SubscriptionPlanId = 1
TenantId = "tenant-xyz", };
SubscriptionPlanId = 1
}; _repositoryMock.Setup(r => r.FindUserByIdAsync("user-abc", It.IsAny<CancellationToken>()))
context.Users.Add(user); .ReturnsAsync(user);
await context.SaveChangesAsync();
} QuizResult? capturedQuizResult = null;
_repositoryMock.Setup(r => r.AddQuizResult(It.IsAny<QuizResult>()))
.Callback<QuizResult>(q => capturedQuizResult = q);
_repositoryMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var command = new SubmitQuizResultCommand( var command = new SubmitQuizResultCommand(
UserId: "user-abc", UserId: "user-abc",
@@ -60,29 +53,30 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable
TotalQuestions: 5 TotalQuestions: 5
); );
var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object); var handler = new SubmitQuizResultCommandHandler(_repositoryMock.Object);
// Act // Act
var result = await handler.Handle(command, CancellationToken.None); var result = await handler.Handle(command, CancellationToken.None);
// Assert // Assert
result.IsSuccess.Should().BeTrue(); result.IsSuccess.Should().BeTrue();
capturedQuizResult.Should().NotBeNull();
capturedQuizResult!.Topic.Should().Be("Sprawdzian: .NET 10");
capturedQuizResult.Score.Should().Be(4);
capturedQuizResult.TotalQuestions.Should().Be(5);
capturedQuizResult.TenantId.Should().Be("tenant-xyz");
using (var context = new AppDbContext(_contextOptions)) _repositoryMock.Verify(r => r.AddQuizResult(It.IsAny<QuizResult>()), Times.Once);
{ _repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
var quizResult = await context.QuizResults.FirstOrDefaultAsync(q => q.UserId == "user-abc");
quizResult.Should().NotBeNull();
quizResult!.Topic.Should().Be("Sprawdzian: .NET 10");
quizResult.Score.Should().Be(4);
quizResult.TotalQuestions.Should().Be(5);
quizResult.TenantId.Should().Be("tenant-xyz");
}
} }
[Fact] [Fact]
public async Task Handle_WithNonExistentUser_ReturnsFailureResult() public async Task Handle_WithNonExistentUser_ReturnsFailureResult()
{ {
// Arrange // Arrange
_repositoryMock.Setup(r => r.FindUserByIdAsync("non-existent", It.IsAny<CancellationToken>()))
.ReturnsAsync((NexusUser?)null);
var command = new SubmitQuizResultCommand( var command = new SubmitQuizResultCommand(
UserId: "non-existent", UserId: "non-existent",
Topic: "Sprawdzian: .NET 10", Topic: "Sprawdzian: .NET 10",
@@ -90,7 +84,7 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable
TotalQuestions: 5 TotalQuestions: 5
); );
var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object); var handler = new SubmitQuizResultCommandHandler(_repositoryMock.Object);
// Act // Act
var result = await handler.Handle(command, CancellationToken.None); var result = await handler.Handle(command, CancellationToken.None);
@@ -98,10 +92,8 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable
// Assert // Assert
result.IsFailed.Should().BeTrue(); result.IsFailed.Should().BeTrue();
result.Errors.Should().ContainSingle(e => e.Message == "User not found."); result.Errors.Should().ContainSingle(e => e.Message == "User not found.");
}
public void Dispose() _repositoryMock.Verify(r => r.AddQuizResult(It.IsAny<QuizResult>()), Times.Never);
{ _repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
_connection.Dispose();
} }
} }