fix: prevent potential component state updates after disposal and implement dedicated repository for quiz results
This commit is contained in:
@@ -23,6 +23,11 @@ public interface IEbookRepository
|
||||
/// </summary>
|
||||
void AddEbook(Ebook ebook);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an ebook by its unique identifier.
|
||||
/// </summary>
|
||||
Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Persists all staged changes to the underlying store.
|
||||
/// </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 System.Linq;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
@@ -79,15 +81,37 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
// 4. Trigger asynchronous background processing and vector indexing
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<IngestEbookCommandHandler>>();
|
||||
var broadcaster = scope.ServiceProvider.GetRequiredService<ISyncBroadcaster>();
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
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.Logging;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Application.Commands.Library;
|
||||
|
||||
@@ -18,20 +18,20 @@ public record ProcessEbookCommand(
|
||||
|
||||
public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, Result<bool>>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly IEbookRepository _ebookRepository;
|
||||
private readonly IKnowledgeService _knowledgeService;
|
||||
private readonly IEpubExtractor _epubExtractor;
|
||||
private readonly ISyncBroadcaster _broadcaster;
|
||||
private readonly ILogger<ProcessEbookCommandHandler> _logger;
|
||||
|
||||
public ProcessEbookCommandHandler(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
IEbookRepository ebookRepository,
|
||||
IKnowledgeService knowledgeService,
|
||||
IEpubExtractor epubExtractor,
|
||||
ISyncBroadcaster broadcaster,
|
||||
ILogger<ProcessEbookCommandHandler> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_ebookRepository = ebookRepository;
|
||||
_knowledgeService = knowledgeService;
|
||||
_epubExtractor = epubExtractor;
|
||||
_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);
|
||||
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var ebook = await dbContext.Ebooks.FindAsync(new object[] { request.EbookId }, cancellationToken);
|
||||
var ebook = await _ebookRepository.FindByIdAsync(request.EbookId, cancellationToken);
|
||||
if (ebook == null)
|
||||
{
|
||||
_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
|
||||
ebook.IsReadyForReading = true;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await _ebookRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title);
|
||||
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Application.Commands.Quiz;
|
||||
|
||||
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)
|
||||
{
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
var user = await _quizResultRepository.FindUserByIdAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Fail("User not found.");
|
||||
@@ -36,8 +33,8 @@ public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizR
|
||||
CompletedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.QuizResults.Add(quizResult);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
_quizResultRepository.AddQuizResult(quizResult);
|
||||
await _quizResultRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
@@ -18,14 +18,15 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
||||
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var profile = await dbContext.Users
|
||||
|
||||
var userRaw = await dbContext.Users
|
||||
.Where(u => u.Id == request.UserId)
|
||||
.Select(u => new UserProfileDto
|
||||
.Select(u => new
|
||||
{
|
||||
Email = u.Email ?? string.Empty,
|
||||
UserId = u.Id,
|
||||
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
|
||||
{
|
||||
Id = u.SubscriptionPlan.Id,
|
||||
@@ -33,12 +34,17 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
||||
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,
|
||||
QuizResults = u.QuizResults.Select(q => new
|
||||
{
|
||||
q.Score,
|
||||
q.TotalQuestions,
|
||||
q.Id,
|
||||
q.Topic,
|
||||
q.Percentage,
|
||||
q.CompletedDate
|
||||
}).ToList(),
|
||||
DisplayName = u.DisplayName,
|
||||
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
|
||||
{
|
||||
Id = e.Id,
|
||||
@@ -55,26 +61,6 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
||||
Description = e.Description,
|
||||
IsReadyForReading = e.IsReadyForReading
|
||||
}).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
|
||||
.Where(ur => ur.UserId == u.Id)
|
||||
.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);
|
||||
|
||||
if (profile == null)
|
||||
if (userRaw == null)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ public static class DependencyInjection
|
||||
|
||||
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
|
||||
|
||||
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
||||
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
||||
|
||||
@@ -46,6 +46,12 @@ internal sealed class EbookRepository : IEbookRepository
|
||||
_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 />
|
||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> _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 const string PromptVersion = "1.7";
|
||||
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
||||
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
|
||||
|
||||
public KnowledgeService(
|
||||
IChatClient chatClient,
|
||||
@@ -454,6 +455,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
|
||||
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _collectionSemaphore.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
|
||||
@@ -474,7 +476,19 @@ public class KnowledgeService : IKnowledgeService
|
||||
}
|
||||
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();
|
||||
}
|
||||
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>();
|
||||
}
|
||||
|
||||
@@ -594,7 +609,10 @@ public class KnowledgeService : IKnowledgeService
|
||||
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}";
|
||||
return new RelevantContext
|
||||
@@ -747,7 +765,10 @@ public class KnowledgeService : IKnowledgeService
|
||||
{
|
||||
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
|
||||
@@ -871,6 +892,8 @@ public class KnowledgeService : IKnowledgeService
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var units = await dbContext.KnowledgeUnits
|
||||
.Include(u => u.Ebook)
|
||||
.ThenInclude(e => e.Author)
|
||||
.Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId))
|
||||
.ToListAsync(cancellationToken);
|
||||
guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u);
|
||||
@@ -916,7 +939,10 @@ public class KnowledgeService : IKnowledgeService
|
||||
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}";
|
||||
}
|
||||
@@ -954,7 +980,10 @@ public class KnowledgeService : IKnowledgeService
|
||||
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}";
|
||||
}
|
||||
@@ -986,7 +1015,10 @@ public class KnowledgeService : IKnowledgeService
|
||||
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}";
|
||||
}
|
||||
@@ -1082,19 +1114,6 @@ public class KnowledgeService : IKnowledgeService
|
||||
{
|
||||
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))
|
||||
{
|
||||
@@ -1106,7 +1125,10 @@ public class KnowledgeService : IKnowledgeService
|
||||
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
|
||||
@using System.Text.RegularExpressions
|
||||
@using MediatR
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@inject IKnowledgeService KnowledgeService
|
||||
@using NexusReader.Application.Queries.Library
|
||||
@inject IMediator Mediator
|
||||
@inject IReaderNavigationService NavService
|
||||
@inject IReaderInteractionService InteractionService
|
||||
@inject NavigationManager NavManager
|
||||
@@ -100,6 +102,7 @@
|
||||
private bool _isLoading;
|
||||
private string? _searchError;
|
||||
private bool _isDropdownOpen;
|
||||
private bool _disposed;
|
||||
|
||||
private CancellationTokenSource? _searchCts;
|
||||
|
||||
@@ -140,15 +143,18 @@
|
||||
{
|
||||
_isLoading = true;
|
||||
_searchError = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
if (!_disposed)
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
||||
|
||||
var result = await KnowledgeService.SearchLibrarySemanticallyAsync(SearchValue, tenantId, Limit, token);
|
||||
if (token.IsCancellationRequested) return;
|
||||
var result = await Mediator.Send(new SearchLibrarySemanticallyQuery(SearchValue, tenantId, Limit), token);
|
||||
if (token.IsCancellationRequested || _disposed) return;
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
@@ -164,7 +170,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!token.IsCancellationRequested)
|
||||
if (!token.IsCancellationRequested && !_disposed)
|
||||
{
|
||||
_results.Clear();
|
||||
_searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania.";
|
||||
@@ -173,7 +179,7 @@
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!token.IsCancellationRequested)
|
||||
if (!token.IsCancellationRequested && !_disposed)
|
||||
{
|
||||
_isLoading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
@@ -291,6 +297,7 @@
|
||||
IsFocused = false;
|
||||
// Delay slightly to allow click handlers on result cards to execute
|
||||
await Task.Delay(200);
|
||||
if (_disposed) return;
|
||||
_isDropdownOpen = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
@@ -305,29 +312,35 @@
|
||||
|
||||
private string HighlightQueryWords(string text, string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(query))
|
||||
return text;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return string.Empty;
|
||||
|
||||
var escapedText = System.Net.WebUtility.HtmlEncode(text);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return escapedText;
|
||||
|
||||
var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(w => w.Length > 2)
|
||||
.Select(Regex.Escape);
|
||||
|
||||
if (!words.Any())
|
||||
return text;
|
||||
return escapedText;
|
||||
|
||||
var pattern = "(" + string.Join("|", words) + ")";
|
||||
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
|
||||
{
|
||||
return text;
|
||||
return escapedText;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_searchCts?.Cancel();
|
||||
_searchCts?.Dispose();
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
private LocalEpubMetadata? Metadata { get; set; }
|
||||
private string? ErrorMessage { get; set; }
|
||||
private byte[]? _epubBytes;
|
||||
private bool _disposed;
|
||||
|
||||
// Allow up to 50 MB
|
||||
private const long MaxFileSize = 50 * 1024 * 1024;
|
||||
@@ -154,23 +155,30 @@
|
||||
|
||||
private async Task HandleIngestionProgress(string message, double progress)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (!IsIndexing) return;
|
||||
|
||||
IngestionStatusMessage = message;
|
||||
IngestionProgressPercent = progress;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
if (!_disposed)
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
if (progress >= 1.0)
|
||||
{
|
||||
// Give the user a moment to see the completion message
|
||||
await Task.Delay(2500);
|
||||
|
||||
if (_disposed) return;
|
||||
|
||||
// Now close the modal and navigate to the book
|
||||
if (IngestedBookId != Guid.Empty)
|
||||
{
|
||||
var bookId = IngestedBookId;
|
||||
await InvokeAsync(async () => {
|
||||
if (_disposed) return;
|
||||
await CloseModal();
|
||||
ReaderNavigation.NavigateToBook(bookId);
|
||||
});
|
||||
@@ -227,10 +235,12 @@
|
||||
using var stream = file.OpenReadStream(MaxFileSize);
|
||||
using var memoryStream = new MemoryStream();
|
||||
await stream.CopyToAsync(memoryStream);
|
||||
if (_disposed) return;
|
||||
_epubBytes = memoryStream.ToArray();
|
||||
|
||||
memoryStream.Position = 0;
|
||||
var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream);
|
||||
if (_disposed) return;
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
@@ -245,12 +255,18 @@
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error uploading EPUB");
|
||||
ErrorMessage = $"An unexpected error occurred: {ex.Message}";
|
||||
if (!_disposed)
|
||||
{
|
||||
ErrorMessage = $"An unexpected error occurred: {ex.Message}";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsParsing = false;
|
||||
StateHasChanged();
|
||||
if (!_disposed)
|
||||
{
|
||||
IsParsing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,10 +289,12 @@
|
||||
);
|
||||
|
||||
var response = await Http.PostAsJsonAsync("api/library/ingest", request);
|
||||
if (_disposed) return;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<IngestResult>();
|
||||
if (_disposed) return;
|
||||
if (result != null)
|
||||
{
|
||||
IngestedBookId = result.Id;
|
||||
@@ -297,12 +315,18 @@
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error during ingestion");
|
||||
ErrorMessage = "Failed to save book to library. Please try again.";
|
||||
IsIngesting = false;
|
||||
if (!_disposed)
|
||||
{
|
||||
ErrorMessage = "Failed to save book to library. Please try again.";
|
||||
IsIngesting = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
StateHasChanged();
|
||||
if (!_disposed)
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +334,7 @@
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_disposed = true;
|
||||
SyncService.OnIngestionProgressReceived -= HandleIngestionProgress;
|
||||
// Clear the large byte array so it is eligible for GC even if the component is cached.
|
||||
_epubBytes = null;
|
||||
|
||||
@@ -50,6 +50,7 @@ builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbCon
|
||||
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
|
||||
builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService());
|
||||
builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository());
|
||||
builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepository());
|
||||
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
|
||||
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 void AddAuthor(Author author) => 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);
|
||||
}
|
||||
|
||||
|
||||
+36
-44
@@ -5,53 +5,46 @@ using FluentAssertions;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Commands.Quiz;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace NexusReader.Application.Tests.Commands;
|
||||
|
||||
public class SubmitQuizResultCommandHandlerTests : IDisposable
|
||||
public class SubmitQuizResultCommandHandlerTests
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<AppDbContext> _contextOptions;
|
||||
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
|
||||
private readonly Mock<IQuizResultRepository> _repositoryMock;
|
||||
|
||||
public SubmitQuizResultCommandHandlerTests()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_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));
|
||||
_repositoryMock = new Mock<IQuizResultRepository>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidRequest_PersistsQuizResultToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
using (var context = new AppDbContext(_contextOptions))
|
||||
var user = new NexusUser
|
||||
{
|
||||
var user = new NexusUser
|
||||
{
|
||||
Id = "user-abc",
|
||||
UserName = "testuser",
|
||||
Email = "test@example.com",
|
||||
TenantId = "tenant-xyz",
|
||||
SubscriptionPlanId = 1
|
||||
};
|
||||
context.Users.Add(user);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
Id = "user-abc",
|
||||
UserName = "testuser",
|
||||
Email = "test@example.com",
|
||||
TenantId = "tenant-xyz",
|
||||
SubscriptionPlanId = 1
|
||||
};
|
||||
|
||||
_repositoryMock.Setup(r => r.FindUserByIdAsync("user-abc", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(user);
|
||||
|
||||
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(
|
||||
UserId: "user-abc",
|
||||
@@ -60,29 +53,30 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable
|
||||
TotalQuestions: 5
|
||||
);
|
||||
|
||||
var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object);
|
||||
var handler = new SubmitQuizResultCommandHandler(_repositoryMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
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))
|
||||
{
|
||||
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");
|
||||
}
|
||||
_repositoryMock.Verify(r => r.AddQuizResult(It.IsAny<QuizResult>()), Times.Once);
|
||||
_repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ReturnsFailureResult()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock.Setup(r => r.FindUserByIdAsync("non-existent", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((NexusUser?)null);
|
||||
|
||||
var command = new SubmitQuizResultCommand(
|
||||
UserId: "non-existent",
|
||||
Topic: "Sprawdzian: .NET 10",
|
||||
@@ -90,7 +84,7 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable
|
||||
TotalQuestions: 5
|
||||
);
|
||||
|
||||
var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object);
|
||||
var handler = new SubmitQuizResultCommandHandler(_repositoryMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
@@ -98,10 +92,8 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable
|
||||
// Assert
|
||||
result.IsFailed.Should().BeTrue();
|
||||
result.Errors.Should().ContainSingle(e => e.Message == "User not found.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
_repositoryMock.Verify(r => r.AddQuizResult(It.IsAny<QuizResult>()), Times.Never);
|
||||
_repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user