From f3b02c85843f5b12964c83b75e2940c6f388e063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 26 May 2026 19:42:24 +0200 Subject: [PATCH] refactor: introduce IConceptsMapReadRepository and SegmentIdParser to improve data access and domain logic encapsulation. --- .../Persistence/IConceptsMapReadRepository.cs | 23 +++++++++ .../GetBookConceptsMapQueryHandler.cs | 40 ++++------------ .../Utilities/SegmentIdParser.cs | 19 ++++++++ .../DependencyInjection.cs | 1 + .../Persistence/ConceptsMapReadRepository.cs | 48 +++++++++++++++++++ .../Components/Organisms/ConceptsMap.razor | 14 ++---- .../Organisms/ConceptsMap.razor.css | 20 ++++---- .../Pages/ConceptsDashboard.razor | 21 ++++---- .../Pages/Library.razor.css | 4 +- .../Pages/SerilogDemo.razor | 13 ++++- .../Pages/Settings.razor | 4 +- .../Pages/Settings.razor.css | 2 +- src/NexusReader.UI.Shared/wwwroot/app.css | 5 ++ .../Services/ServerConceptsMapService.cs | 26 ++++------ .../Services/ServerIdentityService.cs | 4 +- 15 files changed, 157 insertions(+), 87 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Persistence/IConceptsMapReadRepository.cs create mode 100644 src/NexusReader.Application/Utilities/SegmentIdParser.cs create mode 100644 src/NexusReader.Infrastructure/Persistence/ConceptsMapReadRepository.cs diff --git a/src/NexusReader.Application/Abstractions/Persistence/IConceptsMapReadRepository.cs b/src/NexusReader.Application/Abstractions/Persistence/IConceptsMapReadRepository.cs new file mode 100644 index 0000000..e02544f --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Persistence/IConceptsMapReadRepository.cs @@ -0,0 +1,23 @@ +using NexusReader.Domain.Entities; + +namespace NexusReader.Application.Abstractions.Persistence; + +/// +/// Read-only abstraction for fetching concepts map data. +/// Defined in the Application layer to avoid a direct dependency on EF Core / NexusReader.Data. +/// +public interface IConceptsMapReadRepository +{ + /// + /// Gets the last read page ID for the specified user. + /// + Task GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Gets all knowledge units associated with a book, scoped by tenant. + /// + Task> GetKnowledgeUnitsForBookAsync( + Guid bookId, + string tenantId, + CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs b/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs index dab036b..8cdb123 100644 --- a/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs +++ b/src/NexusReader.Application/Queries/Concepts/GetBookConceptsMapQueryHandler.cs @@ -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 { - private readonly IDbContextFactory _dbContextFactory; + private readonly IConceptsMapReadRepository _repository; - public GetBookConceptsMapQueryHandler(IDbContextFactory dbContextFactory) + public GetBookConceptsMapQueryHandler(IConceptsMapReadRepository repository) { - _dbContextFactory = dbContextFactory; + _repository = repository; } public async Task> 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("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(); @@ -99,16 +84,9 @@ internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler 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; - } } diff --git a/src/NexusReader.Application/Utilities/SegmentIdParser.cs b/src/NexusReader.Application/Utilities/SegmentIdParser.cs new file mode 100644 index 0000000..cc0def7 --- /dev/null +++ b/src/NexusReader.Application/Utilities/SegmentIdParser.cs @@ -0,0 +1,19 @@ +namespace NexusReader.Application.Utilities; + +/// +/// Shared utility for parsing numeric segment identifiers from IDs like "seg-42". +/// Centralizes the parsing contract to avoid duplication across handlers and UI components. +/// +public static class SegmentIdParser +{ + /// + /// 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. + /// + 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; + } +} diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 58ea65f..9c39cca 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -121,6 +121,7 @@ public static class DependencyInjection // Fix #1: Ebook repository (scoped, matches AppDbContext lifetime) services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper) services.AddScoped(); diff --git a/src/NexusReader.Infrastructure/Persistence/ConceptsMapReadRepository.cs b/src/NexusReader.Infrastructure/Persistence/ConceptsMapReadRepository.cs new file mode 100644 index 0000000..29062b1 --- /dev/null +++ b/src/NexusReader.Infrastructure/Persistence/ConceptsMapReadRepository.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; + +namespace NexusReader.Infrastructure.Persistence; + +/// +/// EF Core implementation of . +/// Uses for Blazor-safe scoped context creation. +/// +internal sealed class ConceptsMapReadRepository : IConceptsMapReadRepository +{ + private readonly IDbContextFactory _dbContextFactory; + + public ConceptsMapReadRepository(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + /// + public async Task 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; + } + + /// + public async Task> 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); + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor index f95e716..46239da 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor @@ -1,4 +1,5 @@ @using NexusReader.Application.Queries.Graph +@using NexusReader.Application.Utilities @using NexusReader.UI.Shared.Components.Atoms
@@ -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) { diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css index 94bb76e..47220b5 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css @@ -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 { diff --git a/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor index 1d6f464..e4ae704 100644 --- a/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor +++ b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor @@ -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 Mapa Pojęć | Nexus Reader @@ -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; } } diff --git a/src/NexusReader.UI.Shared/Pages/Library.razor.css b/src/NexusReader.UI.Shared/Pages/Library.razor.css index a6d57b6..3fde1ce 100644 --- a/src/NexusReader.UI.Shared/Pages/Library.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Library.razor.css @@ -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); diff --git a/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor index 9de68ef..2c6159c 100644 --- a/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor +++ b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor @@ -2,13 +2,14 @@ @inject ILogger Logger @inject IJSRuntime JSRuntime +#if DEBUG

Serilog Logging Infrastructure

-

Production-grade diagnostic pipeline for unified native & web logs

+

Production-grade diagnostic pipeline for unified native & web logs

@@ -87,9 +88,18 @@
+#else +
+
+

Diagnostics Unavailable

+

This page is only available in DEBUG builds.

+
+
+#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 } diff --git a/src/NexusReader.UI.Shared/Pages/Settings.razor b/src/NexusReader.UI.Shared/Pages/Settings.razor index a08c958..ae288b8 100644 --- a/src/NexusReader.UI.Shared/Pages/Settings.razor +++ b/src/NexusReader.UI.Shared/Pages/Settings.razor @@ -5,13 +5,15 @@

Settings

Configure your account and application preferences.

+ #if DEBUG
-

Diagnostics & System Logs

+

Diagnostics & System Logs

Inspect native logging infrastructure, trigger custom logs, and trace WebView errors.

Open Serilog Diagnostics Dashboard
+ #endif
diff --git a/src/NexusReader.UI.Shared/Pages/Settings.razor.css b/src/NexusReader.UI.Shared/Pages/Settings.razor.css index 16b7053..7df77b0 100644 --- a/src/NexusReader.UI.Shared/Pages/Settings.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Settings.razor.css @@ -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; diff --git a/src/NexusReader.UI.Shared/wwwroot/app.css b/src/NexusReader.UI.Shared/wwwroot/app.css index dc21052..2238d4d 100644 --- a/src/NexusReader.UI.Shared/wwwroot/app.css +++ b/src/NexusReader.UI.Shared/wwwroot/app.css @@ -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 { diff --git a/src/NexusReader.Web/Services/ServerConceptsMapService.cs b/src/NexusReader.Web/Services/ServerConceptsMapService.cs index 5dd01e7..04845e4 100644 --- a/src/NexusReader.Web/Services/ServerConceptsMapService.cs +++ b/src/NexusReader.Web/Services/ServerConceptsMapService.cs @@ -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> GetConceptsMapAsync(Guid bookId) { try { - var authState = await _authStateProvider.GetAuthenticationStateAsync(); - var user = authState.User; - - if (user == null || !user.Identity?.IsAuthenticated == true) - { - user = _httpContextAccessor.HttpContext?.User; - } + var profileResult = await _identityService.GetProfileAsync(); - if (user == null || !user.Identity?.IsAuthenticated == true) + if (profileResult.IsFailed) { return Result.Fail("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)) { diff --git a/src/NexusReader.Web/Services/ServerIdentityService.cs b/src/NexusReader.Web/Services/ServerIdentityService.cs index 6396f19..56b0061 100644 --- a/src/NexusReader.Web/Services/ServerIdentityService.cs +++ b/src/NexusReader.Web/Services/ServerIdentityService.cs @@ -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.");