feat: implement premium gamified Concepts Map dashboard, custom skill-tree, and secure tenant gating

This commit is contained in:
2026-05-26 14:25:00 +02:00
parent a0bf6c15f4
commit 4fd66052ea
8 changed files with 1286 additions and 0 deletions
@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Queries.Graph;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Queries.Concepts;
internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler<GetBookConceptsMapQuery, BookConceptsMapResultDto>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public GetBookConceptsMapQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
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;
// 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 nodes = new List<GraphNodeDto>();
foreach (var unit in units)
{
// Only process units representing sections or conceptual milestones (usually starting with "seg-")
if (string.IsNullOrEmpty(unit.Id) || !unit.Id.StartsWith("seg-"))
{
continue;
}
string label = unit.Id;
string group = "concept";
string summary = unit.Content;
var keyTerms = new List<string>();
if (!string.IsNullOrEmpty(unit.MetadataJson))
{
try
{
using var doc = JsonDocument.Parse(unit.MetadataJson);
if (doc.RootElement.TryGetProperty("label", out var labelProp))
label = labelProp.GetString() ?? label;
if (doc.RootElement.TryGetProperty("group", out var groupProp))
group = groupProp.GetString() ?? group;
if (doc.RootElement.TryGetProperty("summary", out var summaryProp))
summary = summaryProp.GetString() ?? summary;
if (doc.RootElement.TryGetProperty("key_terms", out var ktProp) && ktProp.ValueKind == JsonValueKind.Array)
{
foreach (var term in ktProp.EnumerateArray())
{
if (term.GetString() is string s)
keyTerms.Add(s);
}
}
}
catch
{
// Fallback to defaults
}
}
nodes.Add(new GraphNodeDto(
Id: unit.Id,
Label: label,
Group: group,
Description: unit.Content,
Type: unit.Type.ToString(),
Summary: summary,
KeyTerms: keyTerms
));
}
// Return sorted by the numeric value in the seg-ID to ensure topdown vertical alignment
var sortedNodes = nodes
.OrderBy(n => ParseSegmentNumber(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;
}
}