< Summary

Information
Class: Chronicis.Api.Services.ArticleService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ArticleService.cs
Line coverage
100%
Covered lines: 94
Uncovered lines: 0
Coverable lines: 94
Total lines: 642
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
GetAccessibleArticles(...)100%11100%
GetReadableArticles(...)100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ArticleService.cs

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Shared.DTOs;
 3using Chronicis.Shared.Enums;
 4using Chronicis.Shared.Models;
 5using Chronicis.Shared.Utilities;
 6using Microsoft.EntityFrameworkCore;
 7
 8namespace Chronicis.Api.Services
 9{
 10    public sealed class ArticleService : IArticleService
 11    {
 12        // Compiled queries for hot-path article tree operations.
 13        // Access policy filters are inlined from ReadAccessPolicyService.ApplyAuthenticatedWorldArticleFilter.
 114        private static readonly Func<ChronicisDbContext, Guid, IAsyncEnumerable<ArticleTreeDto>>
 115            GetRootArticlesQuery = EF.CompileAsyncQuery<ChronicisDbContext, Guid, ArticleTreeDto>(
 116                (ChronicisDbContext ctx, Guid userId) => ctx.Articles
 117                    .AsNoTracking()
 118                    .Where(a => a.Type != ArticleType.Tutorial && a.WorldId != Guid.Empty)
 119                    .Where(a => a.World != null && a.World.Members.Any(m => m.UserId == userId))
 120                    .Where(a => a.Visibility != ArticleVisibility.Private || a.CreatedBy == userId)
 121                    .Where(a => a.ParentId == null)
 122                    .Select(a => new ArticleTreeDto
 123                    {
 124                        Id = a.Id,
 125                        Title = a.Title,
 126                        Slug = a.Slug,
 127                        ParentId = a.ParentId,
 128                        WorldId = a.WorldId,
 129                        CampaignId = a.CampaignId,
 130                        ArcId = a.ArcId,
 131                        SessionId = a.SessionId,
 132                        Type = a.Type,
 133                        Visibility = a.Visibility,
 134                        ChildCount = ctx.Articles.Count(c => c.ParentId == a.Id),
 135                        Children = new List<ArticleTreeDto>(),
 136                        CreatedAt = a.CreatedAt,
 137                        EffectiveDate = a.EffectiveDate,
 138                        IconEmoji = a.IconEmoji,
 139                        CreatedBy = a.CreatedBy,
 140                        HasAISummary = a.AISummary != null
 141                    })
 142                    .OrderBy(a => a.Title));
 43
 144        private static readonly Func<ChronicisDbContext, Guid, Guid, IAsyncEnumerable<ArticleTreeDto>>
 145            GetRootArticlesInWorldQuery = EF.CompileAsyncQuery<ChronicisDbContext, Guid, Guid, ArticleTreeDto>(
 146                (ChronicisDbContext ctx, Guid userId, Guid worldId) => ctx.Articles
 147                    .AsNoTracking()
 148                    .Where(a => a.Type != ArticleType.Tutorial && a.WorldId != Guid.Empty)
 149                    .Where(a => a.World != null && a.World.Members.Any(m => m.UserId == userId))
 150                    .Where(a => a.Visibility != ArticleVisibility.Private || a.CreatedBy == userId)
 151                    .Where(a => a.ParentId == null && a.WorldId == worldId)
 152                    .Select(a => new ArticleTreeDto
 153                    {
 154                        Id = a.Id,
 155                        Title = a.Title,
 156                        Slug = a.Slug,
 157                        ParentId = a.ParentId,
 158                        WorldId = a.WorldId,
 159                        CampaignId = a.CampaignId,
 160                        ArcId = a.ArcId,
 161                        SessionId = a.SessionId,
 162                        Type = a.Type,
 163                        Visibility = a.Visibility,
 164                        ChildCount = ctx.Articles.Count(c => c.ParentId == a.Id),
 165                        Children = new List<ArticleTreeDto>(),
 166                        CreatedAt = a.CreatedAt,
 167                        EffectiveDate = a.EffectiveDate,
 168                        IconEmoji = a.IconEmoji,
 169                        CreatedBy = a.CreatedBy,
 170                        HasAISummary = a.AISummary != null
 171                    })
 172                    .OrderBy(a => a.Title));
 73
 174        private static readonly Func<ChronicisDbContext, Guid, Guid, IAsyncEnumerable<ArticleTreeDto>>
 175            GetChildrenCompiledQuery = EF.CompileAsyncQuery<ChronicisDbContext, Guid, Guid, ArticleTreeDto>(
 176                (ChronicisDbContext ctx, Guid parentId, Guid userId) => ctx.Articles
 177                    .AsNoTracking()
 178                    .Where(a => a.Type != ArticleType.Tutorial && a.WorldId != Guid.Empty)
 179                    .Where(a => a.World != null && a.World.Members.Any(m => m.UserId == userId))
 180                    .Where(a => a.Visibility != ArticleVisibility.Private || a.CreatedBy == userId)
 181                    .Where(a => a.ParentId == parentId)
 182                    .Select(a => new ArticleTreeDto
 183                    {
 184                        Id = a.Id,
 185                        Title = a.Title,
 186                        Slug = a.Slug,
 187                        ParentId = a.ParentId,
 188                        WorldId = a.WorldId,
 189                        CampaignId = a.CampaignId,
 190                        ArcId = a.ArcId,
 191                        SessionId = a.SessionId,
 192                        Type = a.Type,
 193                        Visibility = a.Visibility,
 194                        ChildCount = ctx.Articles.Count(c => c.ParentId == a.Id),
 195                        Children = new List<ArticleTreeDto>(),
 196                        CreatedAt = a.CreatedAt,
 197                        EffectiveDate = a.EffectiveDate,
 198                        IconEmoji = a.IconEmoji,
 199                        CreatedBy = a.CreatedBy,
 1100                        HasAISummary = a.AISummary != null
 1101                    })
 1102                    .OrderBy(a => a.Title));
 103
 104        private readonly ChronicisDbContext _context;
 105        private readonly ILogger<ArticleService> _logger;
 106        private readonly IArticleHierarchyService _hierarchyService;
 107        private readonly IReadAccessPolicyService _readAccessPolicy;
 108
 109        public ArticleService(
 110            ChronicisDbContext context,
 111            ILogger<ArticleService> logger,
 112            IArticleHierarchyService hierarchyService,
 113            IReadAccessPolicyService readAccessPolicy)
 114        {
 46115            _context = context;
 46116            _logger = logger;
 46117            _hierarchyService = hierarchyService;
 46118            _readAccessPolicy = readAccessPolicy;
 46119        }
 120
 121        /// <summary>
 122        /// Gets world-scoped articles the user can access via WorldMembers.
 123        /// Tutorial/system articles are explicitly excluded from this query.
 124        /// Private articles are only visible to their creator.
 125        /// </summary>
 126        private IQueryable<Article> GetAccessibleArticles(Guid userId)
 127        {
 39128            return _readAccessPolicy.ApplyAuthenticatedWorldArticleFilter(_context.Articles, userId);
 129        }
 130
 131        /// <summary>
 132        /// Gets articles readable by an authenticated user, including global tutorial articles.
 133        /// </summary>
 134        private IQueryable<Article> GetReadableArticles(Guid userId)
 135        {
 11136            return _readAccessPolicy.ApplyAuthenticatedReadableArticleFilter(_context.Articles, userId);
 137        }
 138
 139        /// <summary>
 140        /// Get all root-level articles (ParentId is null) for worlds the user has access to.
 141        /// Optionally filter by WorldId.
 142        /// </summary>
 143        public async Task<List<ArticleTreeDto>> GetRootArticlesAsync(Guid userId, Guid? worldId = null)
 144        {
 145            var compiled = worldId.HasValue
 146                ? GetRootArticlesInWorldQuery(_context, userId, worldId.Value)
 147                : GetRootArticlesQuery(_context, userId);
 148
 149            var rootArticles = new List<ArticleTreeDto>();
 150            await foreach (var a in compiled)
 151            {
 152                a.HasChildren = a.ChildCount > 0;
 153                rootArticles.Add(a);
 154            }
 155
 156            return rootArticles;
 157        }
 158
 159        /// <summary>
 160        /// Get all articles for worlds the user has access to, in a flat list (no hierarchy).
 161        /// Useful for dropdowns, linking dialogs, etc.
 162        /// Optionally filter by WorldId.
 163        /// </summary>
 164        public async Task<List<ArticleTreeDto>> GetAllArticlesAsync(Guid userId, Guid? worldId = null)
 165        {
 166            var query = GetAccessibleArticles(userId).AsNoTracking();
 167
 168            if (worldId.HasValue)
 169            {
 170                query = query.Where(a => a.WorldId == worldId.Value);
 171            }
 172
 173            var articles = await query
 174                .Select(a => new ArticleTreeDto
 175                {
 176                    Id = a.Id,
 177                    Title = a.Title,
 178                    Slug = a.Slug,
 179                    ParentId = a.ParentId,
 180                    WorldId = a.WorldId,
 181                    CampaignId = a.CampaignId,
 182                    ArcId = a.ArcId,
 183                    SessionId = a.SessionId,
 184                    Type = a.Type,
 185                    Visibility = a.Visibility,
 186                    HasChildren = false, // Not relevant in flat list
 187                    ChildCount = 0,      // Not relevant in flat list
 188                    Children = new List<ArticleTreeDto>(),
 189                    CreatedAt = a.CreatedAt,
 190                    EffectiveDate = a.EffectiveDate,
 191                    IconEmoji = a.IconEmoji,
 192                    CreatedBy = a.CreatedBy,
 193                    HasAISummary = a.AISummary != null
 194                    // Note: Aliases intentionally not loaded here - loaded separately via GetLinkSuggestions
 195                })
 196                .OrderBy(a => a.Title)
 197                .ToListAsync();
 198
 199            return articles;
 200        }
 201
 202        /// <summary>
 203        /// Get all child articles of a specific parent.
 204        /// User must have access to the article's world via WorldMembers.
 205        /// </summary>
 206        public async Task<List<ArticleTreeDto>> GetChildrenAsync(Guid parentId, Guid userId)
 207        {
 208            var children = new List<ArticleTreeDto>();
 209            await foreach (var a in GetChildrenCompiledQuery(_context, parentId, userId))
 210            {
 211                a.HasChildren = a.ChildCount > 0;
 212                children.Add(a);
 213            }
 214
 215            return children;
 216        }
 217
 218        /// <summary>
 219        /// Get full article details including breadcrumb path from root.
 220        /// User must have access to the article's world via WorldMembers.
 221        /// </summary>
 222        public async Task<ArticleDto?> GetArticleDetailAsync(Guid id, Guid userId)
 223        {
 224            var article = await GetReadableArticles(userId)
 225                .AsNoTracking()
 226                .Where(a => a.Id == id)
 227                .Select(ArticleReadModelProjection.ArticleDetail)
 228                .FirstOrDefaultAsync();
 229
 230            if (article == null)
 231            {
 232                _logger.LogWarningSanitized("Article {ArticleId} not found", id);
 233                return null;
 234            }
 235
 236            article.Aliases = await _context.ArticleAliases
 237                .AsNoTracking()
 238                .Where(al => al.ArticleId == id)
 239                .Select(al => new ArticleAliasDto
 240                {
 241                    Id = al.Id,
 242                    AliasText = al.AliasText,
 243                    AliasType = al.AliasType,
 244                    EffectiveDate = al.EffectiveDate,
 245                    CreatedAt = al.CreatedAt
 246                })
 247                .ToListAsync();
 248
 249            // Build breadcrumb path using centralised hierarchy service
 250            article.Breadcrumbs = await _hierarchyService.BuildBreadcrumbsAsync(id);
 251
 252            return article;
 253        }
 254
 255        /// <summary>
 256        /// Move an article to a new parent (or to root if newParentId is null).
 257        /// </summary>
 258        public async Task<(bool Success, string? ErrorMessage)> MoveArticleAsync(Guid articleId, Guid? newParentId, Guid
 259        {
 260            // 1. Get the article to move (must be in a world user has access to)
 261            var article = await GetAccessibleArticles(userId)
 262                .FirstOrDefaultAsync(a => a.Id == articleId);
 263
 264            if (article == null)
 265            {
 266                _logger.LogWarningSanitized("Article {ArticleId} not found for user {UserId}", articleId, userId);
 267                return (false, "Article not found");
 268            }
 269
 270            var sessionChangeRequested = newSessionId.HasValue;
 271
 272            // 2. If moving to same parent (and same session when requested), nothing to do
 273            if (article.ParentId == newParentId &&
 274                (!sessionChangeRequested || article.SessionId == newSessionId))
 275            {
 276                return (true, null);
 277            }
 278
 279            // 3. If newParentId is specified, validate the target exists and user has access
 280            Article? targetParent = null;
 281            if (newParentId.HasValue)
 282            {
 283                targetParent = await GetAccessibleArticles(userId)
 284                    .FirstOrDefaultAsync(a => a.Id == newParentId.Value);
 285
 286                if (targetParent == null)
 287                {
 288                    _logger.LogWarningSanitized("Target parent {NewParentId} not found for user {UserId}", newParentId, 
 289                    return (false, "Target parent article not found");
 290                }
 291
 292                // 4. Check for circular reference - cannot move an article to be a child of itself or its descendants
 293                if (await WouldCreateCircularReferenceAsync(articleId, newParentId.Value, userId))
 294                {
 295                    _logger.LogWarningSanitized("Moving article {ArticleId} to {NewParentId} would create circular refer
 296                        articleId, newParentId);
 297                    return (false, "Cannot move an article to be a child of itself or its descendants");
 298                }
 299            }
 300
 301            Session? targetSession = null;
 302            if (sessionChangeRequested)
 303            {
 304                if (article.Type != ArticleType.SessionNote)
 305                {
 306                    return (false, "Only SessionNote articles can be attached to sessions");
 307                }
 308
 309                targetSession = await _context.Sessions
 310                    .Include(s => s.Arc)
 311                        .ThenInclude(a => a.Campaign)
 312                            .ThenInclude(c => c.World)
 313                                .ThenInclude(w => w.Members)
 314                    .FirstOrDefaultAsync(s => s.Id == newSessionId!.Value);
 315
 316                if (targetSession == null)
 317                {
 318                    return (false, "Target session not found");
 319                }
 320
 321                if (!targetSession.Arc.Campaign.World.Members.Any(m => m.UserId == userId))
 322                {
 323                    return (false, "Target session not found or access denied");
 324                }
 325
 326                if (article.WorldId != targetSession.Arc.Campaign.WorldId)
 327                {
 328                    return (false, "Cannot move articles between different worlds");
 329                }
 330
 331                if (targetParent != null && targetParent.SessionId != targetSession.Id)
 332                {
 333                    return (false, "Target parent must belong to the selected session");
 334                }
 335            }
 336
 337            // 5. Perform the move
 338            article.ParentId = newParentId;
 339
 340            if (targetSession != null)
 341            {
 342                await ReassignSessionContextForSubtreeAsync(
 343                    articleId,
 344                    targetSession.Id,
 345                    targetSession.ArcId,
 346                    targetSession.Arc.CampaignId);
 347            }
 348
 349            article.ModifiedAt = DateTime.UtcNow;
 350            article.LastModifiedBy = userId;
 351
 352            await _context.SaveChangesAsync();
 353
 354            return (true, null);
 355        }
 356
 357        /// <summary>
 358        /// Check if moving articleId to become a child of targetParentId would create a circular reference.
 359        /// </summary>
 360        private async Task<bool> WouldCreateCircularReferenceAsync(Guid articleId, Guid targetParentId, Guid userId)
 361        {
 362            // If trying to move to self, that's circular
 363            if (articleId == targetParentId)
 364            {
 365                return true;
 366            }
 367
 368            // Walk up from targetParentId to root, checking if we encounter articleId
 369            var currentId = (Guid?)targetParentId;
 370            var visited = new HashSet<Guid>();
 371
 372            while (currentId.HasValue)
 373            {
 374                // Prevent infinite loops (shouldn't happen with valid data, but safety first)
 375                if (visited.Contains(currentId.Value))
 376                {
 377                    _logger.LogErrorSanitized("Detected existing circular reference in hierarchy at article {ArticleId}"
 378                    return true;
 379                }
 380                visited.Add(currentId.Value);
 381
 382                // If we find the article we're trying to move in the ancestor chain, it's circular
 383                if (currentId.Value == articleId)
 384                {
 385                    return true;
 386                }
 387
 388                // Move up to parent
 389                var parent = await GetAccessibleArticles(userId)
 390                    .AsNoTracking()
 391                    .Where(a => a.Id == currentId.Value)
 392                    .Select(a => new { a.ParentId })
 393                    .FirstOrDefaultAsync();
 394
 395                currentId = parent?.ParentId;
 396            }
 397
 398            return false;
 399        }
 400
 401        /// <summary>
 402        /// Reassigns Session/Acr/Campaign context for a SessionNote subtree when moving between sessions.
 403        /// </summary>
 404        private async Task ReassignSessionContextForSubtreeAsync(
 405            Guid rootArticleId,
 406            Guid targetSessionId,
 407            Guid targetArcId,
 408            Guid targetCampaignId)
 409        {
 410            var queue = new Queue<Guid>();
 411            var visited = new HashSet<Guid>();
 412            queue.Enqueue(rootArticleId);
 413
 414            while (queue.Count > 0)
 415            {
 416                var currentId = queue.Dequeue();
 417                if (!visited.Add(currentId))
 418                {
 419                    continue;
 420                }
 421
 422                var current = await _context.Articles.FirstOrDefaultAsync(a => a.Id == currentId);
 423                if (current == null)
 424                {
 425                    continue;
 426                }
 427
 428                if (current.Type == ArticleType.SessionNote)
 429                {
 430                    current.SessionId = targetSessionId;
 431                    current.ArcId = targetArcId;
 432                    current.CampaignId = targetCampaignId;
 433                }
 434
 435                var childIds = await _context.Articles
 436                    .Where(a => a.ParentId == currentId)
 437                    .Select(a => a.Id)
 438                    .ToListAsync();
 439
 440                foreach (var childId in childIds)
 441                {
 442                    queue.Enqueue(childId);
 443                }
 444            }
 445        }
 446
 447
 448
 449        /// <summary>
 450        /// Get article by hierarchical path.
 451        /// Path format: "world-slug/article-slug/child-slug" (e.g., "stormlight/wiki/characters").
 452        /// The first segment is the world slug, remaining segments are article hierarchy.
 453        /// </summary>
 454        public async Task<ArticleDto?> GetArticleByPathAsync(string path, Guid userId)
 455        {
 456            if (string.IsNullOrWhiteSpace(path))
 457                return null;
 458
 459            var slugs = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
 460            if (slugs.Length == 0)
 461                return null;
 462
 463            // Normal paths are world-scoped ("world-slug/article-slug/..."), but tutorial
 464            // articles are global/system content (WorldId == Guid.Empty) and arrive as
 465            // "tutorial-slug[/child-slug]". Try world resolution first, then tutorial fallback.
 466            var article = await TryResolveWorldArticleByPathAsync(slugs, userId)
 467                ?? await TryResolveTutorialArticleByPathAsync(slugs, userId);
 468
 469            if (article == null && slugs.Length > 1)
 470            {
 471                // Some tutorial URLs include a synthetic "system tutorial world" prefix
 472                // (e.g. /article/system-tutorial/tutorial-article-any). Tutorials are
 473                // stored as global/system articles, so retry after stripping that prefix.
 474                article = await TryResolveTutorialArticleByPathAsync(slugs.Skip(1).ToArray(), userId);
 475            }
 476
 477            if (article == null)
 478            {
 479                _logger.LogWarningSanitized("Article not found for path '{Path}' for user {UserId}", path, userId);
 480            }
 481
 482            return article;
 483        }
 484
 485        /// <summary>
 486        /// Check if a slug is unique among siblings.
 487        /// For root articles (ParentId is null), checks uniqueness within the World.
 488        /// For child articles, checks uniqueness within the same parent.
 489        /// </summary>
 490        public async Task<bool> IsSlugUniqueAsync(string slug, Guid? parentId, Guid? worldId, Guid userId, Guid? exclude
 491        {
 492            IQueryable<Article> query;
 493
 494            if (parentId.HasValue)
 495            {
 496                // Child article: unique among siblings with same parent
 497                query = GetAccessibleArticles(userId)
 498                    .AsNoTracking()
 499                    .Where(a => a.Slug == slug &&
 500                                a.ParentId == parentId);
 501            }
 502            else
 503            {
 504                // Root article: unique among root articles in the same world
 505                query = GetAccessibleArticles(userId)
 506                    .AsNoTracking()
 507                    .Where(a => a.Slug == slug &&
 508                                a.ParentId == null &&
 509                                a.WorldId == worldId);
 510            }
 511
 512            if (excludeArticleId.HasValue)
 513            {
 514                query = query.Where(a => a.Id != excludeArticleId.Value);
 515            }
 516
 517            return !await query.AnyAsync();
 518        }
 519
 520        /// <summary>
 521        /// Generate a unique slug for an article among its siblings.
 522        /// For root articles (ParentId is null), checks uniqueness within the World.
 523        /// For child articles, checks uniqueness within the same parent.
 524        /// </summary>
 525        public async Task<string> GenerateUniqueSlugAsync(string title, Guid? parentId, Guid? worldId, Guid userId, Guid
 526        {
 527            var baseSlug = SlugGenerator.GenerateSlug(title);
 528
 529            IQueryable<Article> query;
 530
 531            if (parentId.HasValue)
 532            {
 533                // Child article: get slugs from siblings with same parent
 534                query = GetAccessibleArticles(userId)
 535                    .AsNoTracking()
 536                    .Where(a => a.ParentId == parentId);
 537            }
 538            else
 539            {
 540                // Root article: get slugs from root articles in the same world
 541                query = GetAccessibleArticles(userId)
 542                    .AsNoTracking()
 543                    .Where(a => a.ParentId == null && a.WorldId == worldId);
 544            }
 545
 546            var existingSlugs = await query
 547                .Where(a => !excludeArticleId.HasValue || a.Id != excludeArticleId.Value)
 548                .Select(a => a.Slug)
 549                .ToHashSetAsync();
 550
 551            return SlugGenerator.GenerateUniqueSlug(baseSlug, existingSlugs);
 552        }
 553
 554        /// <summary>
 555        /// Build the full hierarchical path for an article, including world slug.
 556        /// Returns format: "world-slug/article-slug/child-slug"
 557        /// </summary>
 558        public async Task<string> BuildArticlePathAsync(Guid articleId, Guid userId)
 559        {
 560            return await _hierarchyService.BuildPathAsync(articleId);
 561        }
 562
 563        private async Task<ArticleDto?> TryResolveWorldArticleByPathAsync(string[] slugs, Guid userId)
 564        {
 565            if (slugs.Length < 2)
 566            {
 567                return null;
 568            }
 569
 570            var worldSlug = slugs[0];
 571
 572            var world = _context.Worlds
 573                .AsNoTracking();
 574
 575            var resolvedWorld = await _readAccessPolicy
 576                .ApplyAuthenticatedWorldFilter(world, userId)
 577                .Where(w => w.Slug == worldSlug)
 578                .Select(w => new { w.Id })
 579                .FirstOrDefaultAsync();
 580
 581            if (resolvedWorld == null)
 582            {
 583                return null;
 584            }
 585
 586            var resolvedArticle = await ArticleSlugPathResolver.ResolveAsync(
 587                slugs.Skip(1).ToArray(),
 588                async (slug, parentId, isRootLevel) =>
 589                {
 590                    var query = GetAccessibleArticles(userId)
 591                        .AsNoTracking()
 592                        .Where(a => a.Slug == slug);
 593
 594                    query = isRootLevel
 595                        ? query.Where(a => a.ParentId == null && a.WorldId == resolvedWorld.Id)
 596                        : query.Where(a => a.ParentId == parentId);
 597
 598                    var article = await query
 599                        .Select(a => new { a.Id, a.Type })
 600                        .FirstOrDefaultAsync();
 601
 602                    return article == null
 603                        ? null
 604                        : (article.Id, article.Type);
 605                });
 606
 607            return resolvedArticle.HasValue
 608                ? await GetArticleDetailAsync(resolvedArticle.Value.Id, userId)
 609                : null;
 610        }
 611
 612        private async Task<ArticleDto?> TryResolveTutorialArticleByPathAsync(string[] slugs, Guid userId)
 613        {
 614            var resolvedArticle = await ArticleSlugPathResolver.ResolveAsync(
 615                slugs,
 616                async (slug, parentId, isRootLevel) =>
 617                {
 618                    var query = _context.Articles
 619                        .AsNoTracking()
 620                        .Where(a => a.Type == ArticleType.Tutorial &&
 621                                    a.WorldId == Guid.Empty &&
 622                                    a.Slug == slug);
 623
 624                    query = isRootLevel
 625                        ? query.Where(a => a.ParentId == null)
 626                        : query.Where(a => a.ParentId == parentId);
 627
 628                    var article = await query
 629                        .Select(a => new { a.Id, a.Type })
 630                        .FirstOrDefaultAsync();
 631
 632                    return article == null
 633                        ? null
 634                        : (article.Id, article.Type);
 635                });
 636
 637            return resolvedArticle.HasValue
 638                ? await GetArticleDetailAsync(resolvedArticle.Value.Id, userId)
 639                : null;
 640        }
 641    }
 642}