feat(search/rag): implement NexusSearchBox, dynamic Qdrant collection auto-provisioning, batch vector ingestion, mobile Serilog logging, and resolve 401 auth handler error (#51)

Resolves #52

This Pull Request introduces the **NexusSearchBox** search feature with premium unified styling, implements a robust **dynamic Qdrant collection auto-provisioning and batch-vector ingestion pipeline**, integrates a unified **Serilog logging infrastructure** for the Blazor Hybrid environment (MAUI), and resolves the **401 Unauthorized API header propagation error** inside mobile builds.

### 🚀 Key Implementations

#### 1. Premium `NexusSearchBox` & Semantic Search UI
* **NexusSearchBox Component:** Created an elegant search-as-you-type search box with smooth key navigation, quick-clearing, and seamless dynamic styling.
* **Unified Aesthetics:** Refactored the search box isolated styling to align perfectly with the dashboard's design system using glassmorphism, `--nexus-neon` token gradients, and smooth pulse/fade animations.
* **Semantic Search Integration:** Integrated semantic search query dispatching (`SearchLibrarySemanticallyQuery`) and wired up navigation seamlessly through the updated `ReaderNavigationService`.
* **Tests Hardening:** Added/adapted query assertions in `QueryTests.cs` to guarantee safe parameterization and error boundary mapping.

#### 2. Qdrant Collection Provisioning & Vector Ingestion
* **Dynamic Auto-Provisioning:** Implemented dynamic checking and lazy-creation of the `knowledge_units` collection using 768 dimensions and Cosine distance.
* **High-Performance Ingestion:** Optimized `ProcessKnowledgeUnitsAsync` with high-performance batch embedding generation using `_embeddingGenerator` and deterministic MD5 GUIDs for stable, duplicate-free upsertion.
* **Database Cache Clear Sync:** Integrated Qdrant collection deletion in `ClearCacheAsync` to ensure absolute consistency between the PostgreSQL database cache and vector database indices.

#### 3. Cross-Platform MAUI Logging (Serilog Infrastructure)
* **Serilog Integration:** Configured cross-platform Serilog routing in `SerilogConfiguration.cs`, streaming diagnostic logs safely across native platforms and the Blazor Webview container.
* **Interop Bridge:** Built `BlazorLoggingBridge.cs` to capture web console messages and pipe them directly to the native host logger.
* **Demo Interface:** Added an interactive `SerilogDemo.razor` sandbox under Pages.

#### 4. Resolving 401 Load Errors (Authentication Handler Flow)
* **Authentication Header Handler:** Implemented the `MobileAuthenticationHeaderHandler` to correctly extract, validate, and inject bearer JWT tokens into outbound API requests.
* **Configuration-based API Host:** Structured standard API URI routing to use clean configuration bindings in `appsettings.json`.

---

### 🧪 Verification & Build Status
* Run `dotnet build` from the solution root: Successfully compiled the full multi-targeted solution (`Liczba błędów: 0`).
* All unit and integration tests successfully executed and verified (`dotnet test`).

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-authored-by: Marek Jaisński <jasins.marek@gmail.com>
Reviewed-on: #51
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #51.
This commit is contained in:
2026-05-26 12:15:28 +00:00
committed by Marek Jaisński
parent 758b152a0c
commit a0bf6c15f4
61 changed files with 5111 additions and 570 deletions
@@ -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);
}
@@ -0,0 +1,17 @@
using FluentResults;
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// Service abstraction to extract raw text content from EPUB chapters.
/// </summary>
public interface IEpubExtractor
{
/// <summary>
/// Extracts the sanitized, plain-text content of each chapter in the EPUB file.
/// </summary>
/// <param name="relativePath">The relative storage path of the EPUB file.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of plain-text chapters, or a failure result.</returns>
Task<Result<List<string>>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default);
}
@@ -11,4 +11,5 @@ public interface IIdentityService
Task<Result> LogoutAsync();
Task<Result<UserProfileDto>> GetProfileAsync();
Task<Result> RefreshTokenAsync();
void ClearCache();
}
@@ -1,5 +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;
@@ -11,13 +14,16 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
{
private readonly IEbookRepository _ebookRepository;
private readonly IBookStorageService _storageService;
private readonly IServiceScopeFactory _scopeFactory;
public IngestEbookCommandHandler(
IEbookRepository ebookRepository,
IBookStorageService storageService)
IBookStorageService storageService,
IServiceScopeFactory scopeFactory)
{
_ebookRepository = ebookRepository;
_storageService = storageService;
_scopeFactory = scopeFactory;
}
public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
@@ -72,6 +78,43 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
_ebookRepository.AddEbook(ebook);
await _ebookRepository.SaveChangesAsync(cancellationToken);
// 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
{
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
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 ex)
{
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
}
}
});
return Result.Ok(ebook.Id);
}
catch (Exception ex)
@@ -0,0 +1,176 @@
using System.Text.RegularExpressions;
using FluentResults;
using MediatR;
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;
namespace NexusReader.Application.Commands.Library;
public record ProcessEbookCommand(
Guid EbookId,
string UserId,
string TenantId
) : ICommand<bool>;
public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, Result<bool>>
{
private readonly IEbookRepository _ebookRepository;
private readonly IKnowledgeService _knowledgeService;
private readonly IEpubExtractor _epubExtractor;
private readonly ISyncBroadcaster _broadcaster;
private readonly ILogger<ProcessEbookCommandHandler> _logger;
public ProcessEbookCommandHandler(
IEbookRepository ebookRepository,
IKnowledgeService knowledgeService,
IEpubExtractor epubExtractor,
ISyncBroadcaster broadcaster,
ILogger<ProcessEbookCommandHandler> logger)
{
_ebookRepository = ebookRepository;
_knowledgeService = knowledgeService;
_epubExtractor = epubExtractor;
_broadcaster = broadcaster;
_logger = logger;
}
public async Task<Result<bool>> Handle(ProcessEbookCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("[ProcessEbook] Starting background processing for Ebook: {EbookId}", request.EbookId);
try
{
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken);
var ebook = await _ebookRepository.FindByIdAsync(request.EbookId, cancellationToken);
if (ebook == null)
{
_logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId);
return Result.Fail<bool>($"Ebook nie znaleziony w bazie danych: {request.EbookId}");
}
_logger.LogInformation("[ProcessEbook] Extracting chapters text for Ebook: {Title} ({FilePath})", ebook.Title, ebook.FilePath);
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Otwieranie i parsowanie pliku EPUB...", 0.1, cancellationToken);
var extractionResult = await _epubExtractor.ExtractChaptersTextAsync(ebook.FilePath, cancellationToken);
if (extractionResult.IsFailed)
{
var errorMsg = extractionResult.Errors.FirstOrDefault()?.Message ?? "Failed to extract text chapters.";
_logger.LogError("[ProcessEbook] Extraction failed: {Error}", errorMsg);
return Result.Fail<bool>(extractionResult.Errors);
}
var chapters = extractionResult.Value;
if (chapters == null || !chapters.Any())
{
_logger.LogWarning("[ProcessEbook] EPUB has no readable content files: {EbookId}", request.EbookId);
return Result.Fail<bool>("EPUB nie zawiera czytelnych rozdziałów.");
}
int totalChapters = chapters.Count;
_logger.LogInformation("[ProcessEbook] Processing {Count} chapters for Ebook: {Title}", totalChapters, ebook.Title);
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, $"Analizowanie struktury ({totalChapters} rozdziałów)...", 0.15, cancellationToken);
int processedChapters = 0;
for (int i = 0; i < totalChapters; i++)
{
var cleanText = chapters[i];
if (cleanText.Length < 100)
{
_logger.LogInformation("[ProcessEbook] Skipping chapter {Index} (text too short: {Length} chars)", i, cleanText.Length);
processedChapters++;
continue;
}
// Chunk the text to maintain granular Knowledge Units
var chunks = ChunkText(cleanText, 3000);
_logger.LogInformation("[ProcessEbook] Chapter {Index} split into {ChunkCount} chunk(s)", i, chunks.Count);
foreach (var chunk in chunks)
{
try
{
// Invoke GetKnowledgeMapAsync to extract, embed, and upsert knowledge units
var result = await _knowledgeService.GetKnowledgeMapAsync(chunk, request.TenantId, request.EbookId, cancellationToken);
if (result.IsFailed)
{
_logger.LogWarning("[ProcessEbook] Failed to generate knowledge map for a chunk of chapter {Index}: {Error}", i, result.Errors.FirstOrDefault()?.Message);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[ProcessEbook] Exception during AI vectorization of chapter {Index} chunk", i);
}
}
processedChapters++;
double progress = 0.15 + (0.75 * processedChapters / totalChapters);
await _broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
$"Przetwarzanie rozdziału {processedChapters} z {totalChapters} przez AI...",
progress,
cancellationToken);
}
// Mark the ebook as ready
ebook.IsReadyForReading = true;
await _ebookRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title);
await _broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
"Indeksowanie wektorowe e-booka przez Nexus AI zakończone pomyślnie!",
1.0,
cancellationToken);
return Result.Ok(true);
}
catch (Exception ex)
{
_logger.LogError(ex, "[ProcessEbook] Critical error during background EPUB vectorization of ebook {EbookId}", request.EbookId);
await _broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
$"Błąd indeksowania: {ex.Message}",
1.0,
cancellationToken);
return Result.Fail<bool>(new Error("Wystąpił błąd podczas indeksowania e-booka przez AI").CausedBy(ex));
}
}
private static List<string> ChunkText(string text, int maxWords = 3000)
{
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var chunks = new List<string>();
if (words.Length <= maxWords)
{
chunks.Add(text);
return chunks;
}
var currentChunk = new List<string>();
int count = 0;
foreach (var word in words)
{
currentChunk.Add(word);
count++;
if (count >= maxWords)
{
chunks.Add(string.Join(" ", currentChunk));
currentChunk.Clear();
count = 0;
}
}
if (currentChunk.Any())
{
chunks.Add(string.Join(" ", currentChunk));
}
return chunks;
}
}
@@ -0,0 +1,10 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Commands.Quiz;
public record SubmitQuizResultCommand(
string UserId,
string Topic,
int Score,
int TotalQuestions) : ICommand;
@@ -0,0 +1,41 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Commands.Quiz;
public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizResultCommand>
{
private readonly IQuizResultRepository _quizResultRepository;
public SubmitQuizResultCommandHandler(IQuizResultRepository quizResultRepository)
{
_quizResultRepository = quizResultRepository;
}
public async Task<Result> Handle(SubmitQuizResultCommand request, CancellationToken cancellationToken)
{
var user = await _quizResultRepository.FindUserByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Fail("User not found.");
}
var quizResult = new QuizResult
{
Id = Guid.NewGuid(),
UserId = request.UserId,
TenantId = string.IsNullOrEmpty(user.TenantId) ? "global" : user.TenantId,
Topic = request.Topic,
Score = request.Score,
TotalQuestions = request.TotalQuestions,
CompletedDate = DateTime.UtcNow
};
_quizResultRepository.AddQuizResult(quizResult);
await _quizResultRepository.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
}
@@ -13,4 +13,6 @@ public class CitationDto
public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID
public string Snippet { get; set; } = string.Empty; // Verified text snippet from context
public string SourceBook { get; set; } = string.Empty; // Book title or description
public string? Author { get; set; }
public int? PageNumber { get; set; }
}
@@ -5,6 +5,7 @@ namespace NexusReader.Application.DTOs.User;
public record UserProfileDto
{
public string Email { get; init; } = string.Empty;
public string UserId { get; init; } = string.Empty;
public int AITokensUsed { get; init; }
public Guid TenantId { get; init; }
@@ -15,11 +16,12 @@ public record UserProfileDto
public int AverageQuizScore { get; init; }
/// <summary>
/// Summary of the last read book.
/// </summary>
public string? DisplayName { get; init; }
public int BooksReadCount { get; init; }
public int ConceptsMappedCount { get; init; }
public LastReadBookDto? LastReadBook { get; init; }
public IReadOnlyList<QuizResultDto> RecentQuizzes { get; init; } = Array.Empty<QuizResultDto>();
public IReadOnlyList<MappedConceptDto> MappedConcepts { get; init; } = Array.Empty<MappedConceptDto>();
public string[] Roles { get; init; } = Array.Empty<string>();
// Helper properties for UI compatibility
@@ -28,6 +30,14 @@ public record UserProfileDto
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
}
public record MappedConceptDto
{
public string Id { get; init; } = string.Empty;
public string Type { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public string DisplayLabel => Content.Length > 25 ? Content.Substring(0, 22) + "..." : Content;
}
public record LastReadBookDto
{
public Guid Id { get; init; }
@@ -38,4 +48,15 @@ public record LastReadBookDto
public string? LastChapter { get; init; }
public int LastChapterIndex { get; init; }
public string? Description { get; init; }
public bool IsReadyForReading { get; init; }
}
public record QuizResultDto
{
public Guid Id { get; init; }
public string Topic { get; init; } = string.Empty;
public int Score { get; init; }
public int TotalQuestions { get; init; }
public double Percentage { get; init; }
public DateTime CompletedDate { get; init; }
}
@@ -1,9 +1,27 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace NexusReader.Application.Queries.Graph;
public record GraphNodeDto(string Id, string Label, string Group, string? Type = null);
public record GraphLinkDto(string Source, string Target, string RelationType, int Value = 1);
public record GraphNodeDto(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("label")] string Label,
[property: JsonPropertyName("group")] string Group,
[property: JsonPropertyName("description")] string? Description = null,
[property: JsonPropertyName("type")] string? Type = null,
[property: JsonPropertyName("summary")] string? Summary = null,
[property: JsonPropertyName("key_terms")] List<string>? KeyTerms = null
);
public record GraphLinkDto(
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("target")] string Target,
[property: JsonPropertyName("type")] string RelationType,
[property: JsonPropertyName("value")] int Value = 1
);
public record GraphDataDto
{
public List<GraphNodeDto> Nodes { get; init; } = new();
public List<GraphLinkDto> Links { get; init; } = new();
[JsonPropertyName("nodes")] public List<GraphNodeDto> Nodes { get; init; } = new();
[JsonPropertyName("links")] public List<GraphLinkDto> Links { get; init; } = new();
}
@@ -37,7 +37,8 @@ public class GetMyEbooksQueryHandler : IRequestHandler<GetMyEbooksQuery, Result<
Progress = e.Progress,
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
LastChapterIndex = e.LastChapterIndex,
Description = e.Description
Description = e.Description,
IsReadyForReading = e.IsReadyForReading
})
.ToListAsync(cancellationToken);
@@ -1,18 +1,7 @@
using FluentResults;
using MediatR;
using Pgvector;
using Pgvector.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.AI;
using Microsoft.Extensions.AI;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Resilience;
using Polly;
using Polly.Registry;
using Mapster;
using MapsterMapper;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Queries.Library;
@@ -21,21 +10,11 @@ public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId,
public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>>
{
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly ResiliencePipeline _retryPipeline;
private readonly IMapper _mapper;
private readonly IKnowledgeService _knowledgeService;
public SearchLibrarySemanticallyQueryHandler(
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
IDbContextFactory<AppDbContext> dbContextFactory,
ResiliencePipelineProvider<string> pipelineProvider,
IMapper mapper)
public SearchLibrarySemanticallyQueryHandler(IKnowledgeService knowledgeService)
{
_embeddingGenerator = embeddingGenerator;
_dbContextFactory = dbContextFactory;
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
_mapper = mapper;
_knowledgeService = knowledgeService;
}
public async Task<Result<List<SemanticSearchResultDto>>> Handle(SearchLibrarySemanticallyQuery request, CancellationToken cancellationToken)
@@ -45,19 +24,10 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
return Result.Fail("Query text cannot be empty.");
}
// Generate embedding with retry
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: ct), cancellationToken);
var queryVector = new Vector(embeddingResponse.First().Vector.ToArray());
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var cacheEntries = await dbContext.SemanticKnowledgeCache
.Where(c => c.TenantId == request.TenantId && c.Embedding != null)
.OrderBy(c => c.Embedding!.CosineDistance(queryVector))
.Take(request.Limit)
.ToListAsync(cancellationToken);
var dtos = _mapper.Map<List<SemanticSearchResultDto>>(cacheEntries);
return Result.Ok(dtos);
return await _knowledgeService.SearchLibrarySemanticallyAsync(
request.QueryText,
request.TenantId,
request.Limit,
cancellationToken);
}
}
@@ -18,13 +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,
@@ -32,9 +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(),
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
{
Id = e.Id,
@@ -48,7 +58,8 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
Progress = e.Progress,
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
LastChapterIndex = e.LastChapterIndex,
Description = e.Description
Description = e.Description,
IsReadyForReading = e.IsReadyForReading
}).FirstOrDefault(),
Roles = dbContext.UserRoles
.Where(ur => ur.UserId == u.Id)
@@ -57,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);
}
}