feat: implement premium gamified Concepts Map dashboard, custom skill-tree, and secure tenant gating
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user