refactor: introduce IConceptsMapReadRepository and SegmentIdParser to improve data access and domain logic encapsulation.

This commit is contained in:
2026-05-26 19:42:24 +02:00
parent 209344cfa0
commit f3b02c8584
15 changed files with 157 additions and 87 deletions
@@ -0,0 +1,23 @@
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Abstractions.Persistence;
/// <summary>
/// Read-only abstraction for fetching concepts map data.
/// Defined in the Application layer to avoid a direct dependency on EF Core / NexusReader.Data.
/// </summary>
public interface IConceptsMapReadRepository
{
/// <summary>
/// Gets the last read page ID for the specified user.
/// </summary>
Task<string?> GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all knowledge units associated with a book, scoped by tenant.
/// </summary>
Task<List<KnowledgeUnit>> GetKnowledgeUnitsForBookAsync(
Guid bookId,
string tenantId,
CancellationToken cancellationToken = default);
}
@@ -5,45 +5,30 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Queries.Graph;
using NexusReader.Data.Persistence;
using NexusReader.Application.Utilities;
namespace NexusReader.Application.Queries.Concepts;
internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler<GetBookConceptsMapQuery, BookConceptsMapResultDto>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly IConceptsMapReadRepository _repository;
public GetBookConceptsMapQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
public GetBookConceptsMapQueryHandler(IConceptsMapReadRepository repository)
{
_dbContextFactory = dbContextFactory;
_repository = repository;
}
public async Task<Result<BookConceptsMapResultDto>> Handle(GetBookConceptsMapQuery request, CancellationToken cancellationToken)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// 1. Fetch user to extract reading progress (LastReadPageId)
var user = await dbContext.Users
.Where(u => u.Id == request.UserId)
.Select(u => new { u.LastReadPageId })
.FirstOrDefaultAsync(cancellationToken);
if (user == null)
{
return Result.Fail<BookConceptsMapResultDto>("User not found.");
}
var lastReadBlockId = user.LastReadPageId ?? string.Empty;
var lastReadPageId = await _repository.GetLastReadPageIdAsync(request.UserId, cancellationToken);
var lastReadBlockId = lastReadPageId ?? string.Empty;
// 2. Fetch all KnowledgeUnits associated with the ebook and user's tenant
var units = await dbContext.KnowledgeUnits
.Where(k => k.EbookId == request.BookId &&
(k.TenantId == request.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)))
.OrderBy(k => k.CreatedAt)
.ToListAsync(cancellationToken);
var units = await _repository.GetKnowledgeUnitsForBookAsync(request.BookId, request.TenantId, cancellationToken);
var nodes = new List<GraphNodeDto>();
@@ -99,16 +84,9 @@ internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler<GetBookConc
// Return sorted by the numeric value in the seg-ID to ensure topdown vertical alignment
var sortedNodes = nodes
.OrderBy(n => ParseSegmentNumber(n.Id))
.OrderBy(n => SegmentIdParser.Parse(n.Id))
.ToList();
return Result.Ok(new BookConceptsMapResultDto(sortedNodes, lastReadBlockId));
}
private static int ParseSegmentNumber(string? id)
{
if (string.IsNullOrEmpty(id)) return 0;
var digits = new string(id.Where(char.IsDigit).ToArray());
return int.TryParse(digits, out var val) ? val : 0;
}
}
@@ -0,0 +1,19 @@
namespace NexusReader.Application.Utilities;
/// <summary>
/// Shared utility for parsing numeric segment identifiers from IDs like "seg-42".
/// Centralizes the parsing contract to avoid duplication across handlers and UI components.
/// </summary>
public static class SegmentIdParser
{
/// <summary>
/// Extracts the numeric portion from a segment identifier string (e.g., "seg-42" → 42).
/// Returns 0 if the string is null, empty, or contains no digits.
/// </summary>
public static int Parse(string? id)
{
if (string.IsNullOrEmpty(id)) return 0;
var digits = new string(id.Where(char.IsDigit).ToArray());
return int.TryParse(digits, out var val) ? val : 0;
}
}
@@ -121,6 +121,7 @@ public static class DependencyInjection
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
services.AddScoped<IEbookRepository, EbookRepository>();
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
@@ -0,0 +1,48 @@
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="IConceptsMapReadRepository"/>.
/// Uses <see cref="IDbContextFactory{TContext}"/> for Blazor-safe scoped context creation.
/// </summary>
internal sealed class ConceptsMapReadRepository : IConceptsMapReadRepository
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public ConceptsMapReadRepository(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
/// <inheritdoc />
public async Task<string?> GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var user = await dbContext.Users
.Where(u => u.Id == userId)
.Select(u => new { u.LastReadPageId })
.FirstOrDefaultAsync(cancellationToken);
return user?.LastReadPageId;
}
/// <inheritdoc />
public async Task<List<KnowledgeUnit>> GetKnowledgeUnitsForBookAsync(
Guid bookId,
string tenantId,
CancellationToken cancellationToken = default)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.KnowledgeUnits
.Where(k => k.EbookId == bookId &&
(k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)))
.OrderBy(k => k.CreatedAt)
.ToListAsync(cancellationToken);
}
}
@@ -1,4 +1,5 @@
@using NexusReader.Application.Queries.Graph
@using NexusReader.Application.Utilities
@using NexusReader.UI.Shared.Components.Atoms
<div class="concepts-map">
@@ -75,10 +76,10 @@
{
if (string.IsNullOrEmpty(nodeId)) return false;
var nodeSeq = ParseSegmentNumber(nodeId);
var nodeSeq = SegmentIdParser.Parse(nodeId);
// Always unlock the very first segment so the user has a starting node
var minNodeSeq = Nodes.Any() ? Nodes.Min(n => ParseSegmentNumber(n.Id)) : 0;
var minNodeSeq = Nodes.Any() ? Nodes.Min(n => SegmentIdParser.Parse(n.Id)) : 0;
if (nodeSeq == minNodeSeq) return true;
if (string.IsNullOrEmpty(LastReadBlockId))
@@ -86,16 +87,11 @@
return false;
}
var progressSeq = ParseSegmentNumber(LastReadBlockId);
var progressSeq = SegmentIdParser.Parse(LastReadBlockId);
return nodeSeq <= progressSeq;
}
private static int ParseSegmentNumber(string? id)
{
if (string.IsNullOrEmpty(id)) return 0;
var digits = new string(id.Where(char.IsDigit).ToArray());
return int.TryParse(digits, out var val) ? val : 0;
}
private async Task HandleNodeClick(GraphNodeDto node)
{
@@ -65,14 +65,14 @@
}
.timeline-step.unlocked:hover {
border-color: rgba(0, 243, 255, 0.15);
box-shadow: 0 4px 20px rgba(0, 243, 255, 0.05);
border-color: rgba(0, 255, 153, 0.15);
box-shadow: 0 4px 20px rgba(0, 255, 153, 0.05);
}
.timeline-step.selected {
background: rgba(0, 243, 255, 0.04);
background: rgba(0, 255, 153, 0.04);
border-color: var(--nexus-neon);
box-shadow: 0 0 15px rgba(0, 243, 255, 0.1);
box-shadow: 0 0 15px var(--nexus-primary-glow);
}
.node-connector-wrapper {
@@ -100,7 +100,7 @@
.unlocked .node-circle {
border: 2px solid var(--nexus-neon);
color: var(--nexus-neon);
box-shadow: 0 0 10px rgba(0, 243, 255, 0.3);
box-shadow: 0 0 10px var(--nexus-primary-glow);
}
.locked .node-circle {
@@ -136,8 +136,8 @@
}
.track-active {
background: linear-gradient(180deg, var(--nexus-neon), rgba(0, 243, 255, 0.2));
box-shadow: 0 0 6px rgba(0, 243, 255, 0.2);
background: linear-gradient(180deg, var(--nexus-neon), rgba(0, 255, 153, 0.2));
box-shadow: 0 0 6px var(--nexus-primary-glow);
}
.track-inactive {
@@ -159,7 +159,7 @@
.timeline-step.selected .node-content {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(0, 243, 255, 0.2);
border-color: rgba(0, 255, 153, 0.2);
}
.node-header {
@@ -188,9 +188,9 @@
}
.badge-unlocked {
background: rgba(0, 243, 255, 0.08);
background: rgba(0, 255, 153, 0.08);
color: var(--nexus-neon);
border: 1px solid rgba(0, 243, 255, 0.2);
border: 1px solid rgba(0, 255, 153, 0.2);
}
.badge-locked {
@@ -8,12 +8,13 @@
@using NexusReader.Application.Queries.Concepts
@using System.Net.Http.Json
@using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.Utilities
@inject IConceptsMapService ConceptsMapService
@inject NavigationManager NavigationManager
@inject IIdentityService IdentityService
@inject ISyncService SyncService
@attribute [Authorize]
@implements IDisposable
@implements IAsyncDisposable
<PageTitle>Mapa Pojęć | Nexus Reader</PageTitle>
@@ -235,23 +236,18 @@
private bool IsUnlocked(string nodeId)
{
if (string.IsNullOrEmpty(nodeId)) return false;
var nodeSeq = ParseSegmentNumber(nodeId);
var nodeSeq = SegmentIdParser.Parse(nodeId);
var minNodeSeq = Nodes.Any() ? Nodes.Min(n => ParseSegmentNumber(n.Id)) : 0;
var minNodeSeq = Nodes.Any() ? Nodes.Min(n => SegmentIdParser.Parse(n.Id)) : 0;
if (nodeSeq == minNodeSeq) return true;
if (string.IsNullOrEmpty(LastReadBlockId)) return false;
var progressSeq = ParseSegmentNumber(LastReadBlockId);
var progressSeq = SegmentIdParser.Parse(LastReadBlockId);
return nodeSeq <= progressSeq;
}
private static int ParseSegmentNumber(string? id)
{
if (string.IsNullOrEmpty(id)) return 0;
var digits = new string(id.Where(char.IsDigit).ToArray());
return int.TryParse(digits, out var val) ? val : 0;
}
private void HandleNodeSelected(GraphNodeDto node)
{
@@ -276,7 +272,7 @@
{
if (BookId.HasValue && SelectedNode != null)
{
var chapterIndex = ParseSegmentNumber(SelectedNode.Id);
var chapterIndex = SegmentIdParser.Parse(SelectedNode.Id);
NavigationManager.NavigateTo($"/reader/{BookId.Value}?chapter={chapterIndex}");
}
}
@@ -299,9 +295,10 @@
});
}
public void Dispose()
public ValueTask DisposeAsync()
{
IdentityService.OnStateInvalidated -= HandleStateInvalidatedAsync;
SyncService.OnProgressReceived -= HandleProgressReceivedAsync;
return ValueTask.CompletedTask;
}
}
@@ -31,7 +31,7 @@
margin: 0;
}
::deep .add-book-trigger {
.add-book-trigger {
background: var(--nexus-neon) !important;
color: #000000 !important;
border: none !important;
@@ -41,7 +41,7 @@
border-radius: var(--radius-md) !important;
}
::deep .add-book-trigger:hover {
.add-book-trigger:hover {
transform: translateY(-2px) !important;
box-shadow: 0 8px 20px rgba(0, 255, 153, 0.5) !important;
filter: brightness(1.1);
@@ -2,13 +2,14 @@
@inject ILogger<SerilogDemo> Logger
@inject IJSRuntime JSRuntime
#if DEBUG
<div class="serilog-demo-container">
<div class="header-card glass-panel">
<div class="header-content">
<NexusIcon Name="cpu" Size="36" Class="header-icon" />
<div class="header-text">
<h1>Serilog Logging Infrastructure</h1>
<p class="subtitle">Production-grade diagnostic pipeline for unified native & web logs</p>
<p class="subtitle">Production-grade diagnostic pipeline for unified native &amp; web logs</p>
</div>
</div>
<div class="status-badge">
@@ -87,9 +88,18 @@
</div>
</div>
</div>
#else
<div class="serilog-demo-container">
<div class="glass-panel" style="text-align: center; padding: 3rem;">
<h2>Diagnostics Unavailable</h2>
<p>This page is only available in DEBUG builds.</p>
</div>
</div>
#endif
@code {
#if DEBUG
private void LogInfo()
{
Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo");
@@ -121,4 +131,5 @@
{
await JSRuntime.InvokeVoidAsync("eval", "throw new Error('Simulated runtime JS Exception triggered from Blazor UI button click!');");
}
#endif
}
@@ -5,13 +5,15 @@
<h1>Settings</h1>
<p>Configure your account and application preferences.</p>
#if DEBUG
<div class="settings-section glass-panel">
<h2>Diagnostics & System Logs</h2>
<h2>Diagnostics &amp; System Logs</h2>
<p>Inspect native logging infrastructure, trigger custom logs, and trace WebView errors.</p>
<a class="diag-btn" href="/serilog-demo">
<NexusIcon Name="cpu" Size="16" />
Open Serilog Diagnostics Dashboard
</a>
</div>
#endif
</div>
@@ -5,7 +5,7 @@
animation: fadeIn 0.6s ease-out;
}
h1 {
.settings-page > h1 {
font-family: var(--nexus-font-serif);
font-size: 2.8rem;
font-weight: 700;
@@ -73,8 +73,13 @@
.btn-nexus:hover {
transform: translateY(-2px);
filter: brightness(1.1);
}
.btn-nexus-primary:hover {
box-shadow: 0 4px 15px var(--nexus-primary-glow);
}
.btn-nexus-secondary:hover {
box-shadow: 0 4px 15px rgba(255, 255, 255, 0.05);
}
.theme-light {
@@ -1,8 +1,6 @@
using System.Security.Claims;
using FluentResults;
using MediatR;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Concepts;
@@ -11,38 +9,30 @@ namespace NexusReader.Web.Services;
public class ServerConceptsMapService : IConceptsMapService
{
private readonly IMediator _mediator;
private readonly AuthenticationStateProvider _authStateProvider;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IIdentityService _identityService;
public ServerConceptsMapService(
IMediator mediator,
AuthenticationStateProvider authStateProvider,
IHttpContextAccessor httpContextAccessor)
IIdentityService identityService)
{
_mediator = mediator;
_authStateProvider = authStateProvider;
_httpContextAccessor = httpContextAccessor;
_identityService = identityService;
}
public async Task<Result<BookConceptsMapResultDto>> GetConceptsMapAsync(Guid bookId)
{
try
{
var authState = await _authStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
var profileResult = await _identityService.GetProfileAsync();
if (user == null || !user.Identity?.IsAuthenticated == true)
{
user = _httpContextAccessor.HttpContext?.User;
}
if (user == null || !user.Identity?.IsAuthenticated == true)
if (profileResult.IsFailed)
{
return Result.Fail<BookConceptsMapResultDto>("Użytkownik nie jest uwierzytelniony.");
}
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var profile = profileResult.Value;
var userId = profile.UserId;
var tenantId = profile.TenantId.ToString();
if (string.IsNullOrEmpty(userId))
{
@@ -114,12 +114,12 @@ public class ServerIdentityService : IIdentityService
var authState = await _authStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user == null || !user.Identity?.IsAuthenticated == true)
if (user == null || user.Identity?.IsAuthenticated != true)
{
user = _httpContextAccessor.HttpContext?.User;
}
if (user == null || !user.Identity?.IsAuthenticated == true) return Result.Fail("Not authenticated.");
if (user == null || user.Identity?.IsAuthenticated != true) return Result.Fail("Not authenticated.");
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Result.Fail("User ID not found.");