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;
using System.Threading.Tasks; using System.Threading.Tasks;
using FluentResults; using FluentResults;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging; using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Queries.Graph; using NexusReader.Application.Queries.Graph;
using NexusReader.Data.Persistence; using NexusReader.Application.Utilities;
namespace NexusReader.Application.Queries.Concepts; namespace NexusReader.Application.Queries.Concepts;
internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler<GetBookConceptsMapQuery, BookConceptsMapResultDto> 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) 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) // 1. Fetch user to extract reading progress (LastReadPageId)
var user = await dbContext.Users var lastReadPageId = await _repository.GetLastReadPageIdAsync(request.UserId, cancellationToken);
.Where(u => u.Id == request.UserId) var lastReadBlockId = lastReadPageId ?? string.Empty;
.Select(u => new { u.LastReadPageId })
.FirstOrDefaultAsync(cancellationToken);
if (user == null)
{
return Result.Fail<BookConceptsMapResultDto>("User not found.");
}
var lastReadBlockId = user.LastReadPageId ?? string.Empty;
// 2. Fetch all KnowledgeUnits associated with the ebook and user's tenant // 2. Fetch all KnowledgeUnits associated with the ebook and user's tenant
var units = await dbContext.KnowledgeUnits var units = await _repository.GetKnowledgeUnitsForBookAsync(request.BookId, request.TenantId, cancellationToken);
.Where(k => k.EbookId == request.BookId &&
(k.TenantId == request.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)))
.OrderBy(k => k.CreatedAt)
.ToListAsync(cancellationToken);
var nodes = new List<GraphNodeDto>(); 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 // Return sorted by the numeric value in the seg-ID to ensure topdown vertical alignment
var sortedNodes = nodes var sortedNodes = nodes
.OrderBy(n => ParseSegmentNumber(n.Id)) .OrderBy(n => SegmentIdParser.Parse(n.Id))
.ToList(); .ToList();
return Result.Ok(new BookConceptsMapResultDto(sortedNodes, lastReadBlockId)); 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) // Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
services.AddScoped<IEbookRepository, EbookRepository>(); services.AddScoped<IEbookRepository, EbookRepository>();
services.AddScoped<IQuizResultRepository, QuizResultRepository>(); services.AddScoped<IQuizResultRepository, QuizResultRepository>();
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
// 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>();
@@ -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.Queries.Graph
@using NexusReader.Application.Utilities
@using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Components.Atoms
<div class="concepts-map"> <div class="concepts-map">
@@ -75,10 +76,10 @@
{ {
if (string.IsNullOrEmpty(nodeId)) return false; 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 // 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 (nodeSeq == minNodeSeq) return true;
if (string.IsNullOrEmpty(LastReadBlockId)) if (string.IsNullOrEmpty(LastReadBlockId))
@@ -86,16 +87,11 @@
return false; return false;
} }
var progressSeq = ParseSegmentNumber(LastReadBlockId); var progressSeq = SegmentIdParser.Parse(LastReadBlockId);
return nodeSeq <= progressSeq; 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) private async Task HandleNodeClick(GraphNodeDto node)
{ {
@@ -65,14 +65,14 @@
} }
.timeline-step.unlocked:hover { .timeline-step.unlocked:hover {
border-color: rgba(0, 243, 255, 0.15); border-color: rgba(0, 255, 153, 0.15);
box-shadow: 0 4px 20px rgba(0, 243, 255, 0.05); box-shadow: 0 4px 20px rgba(0, 255, 153, 0.05);
} }
.timeline-step.selected { .timeline-step.selected {
background: rgba(0, 243, 255, 0.04); background: rgba(0, 255, 153, 0.04);
border-color: var(--nexus-neon); 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 { .node-connector-wrapper {
@@ -100,7 +100,7 @@
.unlocked .node-circle { .unlocked .node-circle {
border: 2px solid var(--nexus-neon); border: 2px solid var(--nexus-neon);
color: 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 { .locked .node-circle {
@@ -136,8 +136,8 @@
} }
.track-active { .track-active {
background: linear-gradient(180deg, var(--nexus-neon), rgba(0, 243, 255, 0.2)); background: linear-gradient(180deg, var(--nexus-neon), rgba(0, 255, 153, 0.2));
box-shadow: 0 0 6px rgba(0, 243, 255, 0.2); box-shadow: 0 0 6px var(--nexus-primary-glow);
} }
.track-inactive { .track-inactive {
@@ -159,7 +159,7 @@
.timeline-step.selected .node-content { .timeline-step.selected .node-content {
background: rgba(255, 255, 255, 0.03); 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 { .node-header {
@@ -188,9 +188,9 @@
} }
.badge-unlocked { .badge-unlocked {
background: rgba(0, 243, 255, 0.08); background: rgba(0, 255, 153, 0.08);
color: var(--nexus-neon); color: var(--nexus-neon);
border: 1px solid rgba(0, 243, 255, 0.2); border: 1px solid rgba(0, 255, 153, 0.2);
} }
.badge-locked { .badge-locked {
@@ -8,12 +8,13 @@
@using NexusReader.Application.Queries.Concepts @using NexusReader.Application.Queries.Concepts
@using System.Net.Http.Json @using System.Net.Http.Json
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.Utilities
@inject IConceptsMapService ConceptsMapService @inject IConceptsMapService ConceptsMapService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject ISyncService SyncService @inject ISyncService SyncService
@attribute [Authorize] @attribute [Authorize]
@implements IDisposable @implements IAsyncDisposable
<PageTitle>Mapa Pojęć | Nexus Reader</PageTitle> <PageTitle>Mapa Pojęć | Nexus Reader</PageTitle>
@@ -235,23 +236,18 @@
private bool IsUnlocked(string nodeId) private bool IsUnlocked(string nodeId)
{ {
if (string.IsNullOrEmpty(nodeId)) return false; 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 (nodeSeq == minNodeSeq) return true;
if (string.IsNullOrEmpty(LastReadBlockId)) return false; if (string.IsNullOrEmpty(LastReadBlockId)) return false;
var progressSeq = ParseSegmentNumber(LastReadBlockId); var progressSeq = SegmentIdParser.Parse(LastReadBlockId);
return nodeSeq <= progressSeq; 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) private void HandleNodeSelected(GraphNodeDto node)
{ {
@@ -276,7 +272,7 @@
{ {
if (BookId.HasValue && SelectedNode != null) if (BookId.HasValue && SelectedNode != null)
{ {
var chapterIndex = ParseSegmentNumber(SelectedNode.Id); var chapterIndex = SegmentIdParser.Parse(SelectedNode.Id);
NavigationManager.NavigateTo($"/reader/{BookId.Value}?chapter={chapterIndex}"); NavigationManager.NavigateTo($"/reader/{BookId.Value}?chapter={chapterIndex}");
} }
} }
@@ -299,9 +295,10 @@
}); });
} }
public void Dispose() public ValueTask DisposeAsync()
{ {
IdentityService.OnStateInvalidated -= HandleStateInvalidatedAsync; IdentityService.OnStateInvalidated -= HandleStateInvalidatedAsync;
SyncService.OnProgressReceived -= HandleProgressReceivedAsync; SyncService.OnProgressReceived -= HandleProgressReceivedAsync;
return ValueTask.CompletedTask;
} }
} }
@@ -31,7 +31,7 @@
margin: 0; margin: 0;
} }
::deep .add-book-trigger { .add-book-trigger {
background: var(--nexus-neon) !important; background: var(--nexus-neon) !important;
color: #000000 !important; color: #000000 !important;
border: none !important; border: none !important;
@@ -41,7 +41,7 @@
border-radius: var(--radius-md) !important; border-radius: var(--radius-md) !important;
} }
::deep .add-book-trigger:hover { .add-book-trigger:hover {
transform: translateY(-2px) !important; transform: translateY(-2px) !important;
box-shadow: 0 8px 20px rgba(0, 255, 153, 0.5) !important; box-shadow: 0 8px 20px rgba(0, 255, 153, 0.5) !important;
filter: brightness(1.1); filter: brightness(1.1);
@@ -2,13 +2,14 @@
@inject ILogger<SerilogDemo> Logger @inject ILogger<SerilogDemo> Logger
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
#if DEBUG
<div class="serilog-demo-container"> <div class="serilog-demo-container">
<div class="header-card glass-panel"> <div class="header-card glass-panel">
<div class="header-content"> <div class="header-content">
<NexusIcon Name="cpu" Size="36" Class="header-icon" /> <NexusIcon Name="cpu" Size="36" Class="header-icon" />
<div class="header-text"> <div class="header-text">
<h1>Serilog Logging Infrastructure</h1> <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> </div>
<div class="status-badge"> <div class="status-badge">
@@ -87,9 +88,18 @@
</div> </div>
</div> </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 { @code {
#if DEBUG
private void LogInfo() private void LogInfo()
{ {
Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: 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!');"); 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> <h1>Settings</h1>
<p>Configure your account and application preferences.</p> <p>Configure your account and application preferences.</p>
#if DEBUG
<div class="settings-section glass-panel"> <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> <p>Inspect native logging infrastructure, trigger custom logs, and trace WebView errors.</p>
<a class="diag-btn" href="/serilog-demo"> <a class="diag-btn" href="/serilog-demo">
<NexusIcon Name="cpu" Size="16" /> <NexusIcon Name="cpu" Size="16" />
Open Serilog Diagnostics Dashboard Open Serilog Diagnostics Dashboard
</a> </a>
</div> </div>
#endif
</div> </div>
@@ -5,7 +5,7 @@
animation: fadeIn 0.6s ease-out; animation: fadeIn 0.6s ease-out;
} }
h1 { .settings-page > h1 {
font-family: var(--nexus-font-serif); font-family: var(--nexus-font-serif);
font-size: 2.8rem; font-size: 2.8rem;
font-weight: 700; font-weight: 700;
@@ -73,8 +73,13 @@
.btn-nexus:hover { .btn-nexus:hover {
transform: translateY(-2px); transform: translateY(-2px);
filter: brightness(1.1); filter: brightness(1.1);
}
.btn-nexus-primary:hover {
box-shadow: 0 4px 15px var(--nexus-primary-glow); 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 { .theme-light {
@@ -1,8 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using FluentResults; using FluentResults;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Concepts; using NexusReader.Application.Queries.Concepts;
@@ -11,38 +9,30 @@ namespace NexusReader.Web.Services;
public class ServerConceptsMapService : IConceptsMapService public class ServerConceptsMapService : IConceptsMapService
{ {
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly AuthenticationStateProvider _authStateProvider; private readonly IIdentityService _identityService;
private readonly IHttpContextAccessor _httpContextAccessor;
public ServerConceptsMapService( public ServerConceptsMapService(
IMediator mediator, IMediator mediator,
AuthenticationStateProvider authStateProvider, IIdentityService identityService)
IHttpContextAccessor httpContextAccessor)
{ {
_mediator = mediator; _mediator = mediator;
_authStateProvider = authStateProvider; _identityService = identityService;
_httpContextAccessor = httpContextAccessor;
} }
public async Task<Result<BookConceptsMapResultDto>> GetConceptsMapAsync(Guid bookId) public async Task<Result<BookConceptsMapResultDto>> GetConceptsMapAsync(Guid bookId)
{ {
try try
{ {
var authState = await _authStateProvider.GetAuthenticationStateAsync(); var profileResult = await _identityService.GetProfileAsync();
var user = authState.User;
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."); return Result.Fail<BookConceptsMapResultDto>("Użytkownik nie jest uwierzytelniony.");
} }
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var profile = profileResult.Value;
var tenantId = user.FindFirstValue("TenantId") ?? "global"; var userId = profile.UserId;
var tenantId = profile.TenantId.ToString();
if (string.IsNullOrEmpty(userId)) if (string.IsNullOrEmpty(userId))
{ {
@@ -114,12 +114,12 @@ public class ServerIdentityService : IIdentityService
var authState = await _authStateProvider.GetAuthenticationStateAsync(); var authState = await _authStateProvider.GetAuthenticationStateAsync();
var user = authState.User; var user = authState.User;
if (user == null || !user.Identity?.IsAuthenticated == true) if (user == null || user.Identity?.IsAuthenticated != true)
{ {
user = _httpContextAccessor.HttpContext?.User; 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); var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Result.Fail("User ID not found."); if (userId == null) return Result.Fail("User ID not found.");