refactor: introduce IConceptsMapReadRepository and SegmentIdParser to improve data access and domain logic encapsulation.
This commit is contained in:
@@ -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 & 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 & 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;
|
||||
|
||||
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<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.");
|
||||
|
||||
Reference in New Issue
Block a user