< 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: 787
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        {
 57115            _context = context;
 57116            _logger = logger;
 57117            _hierarchyService = hierarchyService;
 57118            _readAccessPolicy = readAccessPolicy;
 57119        }
 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        {
 42128            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        {
 8136            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            // Virtual groups (Wiki / Player Characters / Campaign-Arc-Session) are required
 251            // for the breadcrumb chain to produce valid URL paths under the slug routing scheme.
 252            HierarchyWalkOptions? options = null;
 253            if (article.WorldId.HasValue && article.WorldId.Value != Guid.Empty)
 254            {
 255                var world = await _context.Worlds
 256                    .AsNoTracking()
 257                    .Where(w => w.Id == article.WorldId.Value)
 258                    .Select(w => new WorldContext { Id = w.Id, Name = w.Name, Slug = w.Slug })
 259                    .FirstOrDefaultAsync();
 260
 261                if (world != null)
 262                {
 263                    options = new HierarchyWalkOptions
 264                    {
 265                        IncludeVirtualGroups = true,
 266                        World = world
 267                    };
 268                }
 269            }
 270
 271            article.Breadcrumbs = await _hierarchyService.BuildBreadcrumbsAsync(id, options);
 272
 273            return article;
 274        }
 275
 276        /// <summary>
 277        /// Move an article to a new parent (or to root if newParentId is null).
 278        /// </summary>
 279        public async Task<(bool Success, string? ErrorMessage)> MoveArticleAsync(Guid articleId, Guid? newParentId, Guid
 280        {
 281            // 1. Get the article to move (must be in a world user has access to)
 282            var article = await GetAccessibleArticles(userId)
 283                .FirstOrDefaultAsync(a => a.Id == articleId);
 284
 285            if (article == null)
 286            {
 287                _logger.LogWarningSanitized("Article {ArticleId} not found for user {UserId}", articleId, userId);
 288                return (false, "Article not found");
 289            }
 290
 291            var sessionChangeRequested = newSessionId.HasValue;
 292
 293            // 2. If moving to same parent (and same session when requested), nothing to do
 294            if (article.ParentId == newParentId &&
 295                (!sessionChangeRequested || article.SessionId == newSessionId))
 296            {
 297                return (true, null);
 298            }
 299
 300            // 3. If newParentId is specified, validate the target exists and user has access
 301            Article? targetParent = null;
 302            if (newParentId.HasValue)
 303            {
 304                targetParent = await GetAccessibleArticles(userId)
 305                    .FirstOrDefaultAsync(a => a.Id == newParentId.Value);
 306
 307                if (targetParent == null)
 308                {
 309                    _logger.LogWarningSanitized("Target parent {NewParentId} not found for user {UserId}", newParentId, 
 310                    return (false, "Target parent article not found");
 311                }
 312
 313                // 4. Check for circular reference - cannot move an article to be a child of itself or its descendants
 314                if (await WouldCreateCircularReferenceAsync(articleId, newParentId.Value, userId))
 315                {
 316                    _logger.LogWarningSanitized("Moving article {ArticleId} to {NewParentId} would create circular refer
 317                        articleId, newParentId);
 318                    return (false, "Cannot move an article to be a child of itself or its descendants");
 319                }
 320            }
 321
 322            Session? targetSession = null;
 323            if (sessionChangeRequested)
 324            {
 325                if (article.Type != ArticleType.SessionNote)
 326                {
 327                    return (false, "Only SessionNote articles can be attached to sessions");
 328                }
 329
 330                targetSession = await _context.Sessions
 331                    .Include(s => s.Arc)
 332                        .ThenInclude(a => a.Campaign)
 333                            .ThenInclude(c => c.World)
 334                                .ThenInclude(w => w.Members)
 335                    .FirstOrDefaultAsync(s => s.Id == newSessionId!.Value);
 336
 337                if (targetSession == null)
 338                {
 339                    return (false, "Target session not found");
 340                }
 341
 342                if (!targetSession.Arc.Campaign.World.Members.Any(m => m.UserId == userId))
 343                {
 344                    return (false, "Target session not found or access denied");
 345                }
 346
 347                if (article.WorldId != targetSession.Arc.Campaign.WorldId)
 348                {
 349                    return (false, "Cannot move articles between different worlds");
 350                }
 351
 352                if (targetParent != null && targetParent.SessionId != targetSession.Id)
 353                {
 354                    return (false, "Target parent must belong to the selected session");
 355                }
 356            }
 357
 358            // 5. Perform the move
 359            article.ParentId = newParentId;
 360
 361            if (targetSession != null)
 362            {
 363                await ReassignSessionContextForSubtreeAsync(
 364                    articleId,
 365                    targetSession.Id,
 366                    targetSession.ArcId,
 367                    targetSession.Arc.CampaignId);
 368            }
 369
 370            article.ModifiedAt = DateTime.UtcNow;
 371            article.LastModifiedBy = userId;
 372
 373            await _context.SaveChangesAsync();
 374
 375            return (true, null);
 376        }
 377
 378        /// <summary>
 379        /// Check if moving articleId to become a child of targetParentId would create a circular reference.
 380        /// </summary>
 381        private async Task<bool> WouldCreateCircularReferenceAsync(Guid articleId, Guid targetParentId, Guid userId)
 382        {
 383            // If trying to move to self, that's circular
 384            if (articleId == targetParentId)
 385            {
 386                return true;
 387            }
 388
 389            // Walk up from targetParentId to root, checking if we encounter articleId
 390            var currentId = (Guid?)targetParentId;
 391            var visited = new HashSet<Guid>();
 392
 393            while (currentId.HasValue)
 394            {
 395                // Prevent infinite loops (shouldn't happen with valid data, but safety first)
 396                if (visited.Contains(currentId.Value))
 397                {
 398                    _logger.LogErrorSanitized("Detected existing circular reference in hierarchy at article {ArticleId}"
 399                    return true;
 400                }
 401                visited.Add(currentId.Value);
 402
 403                // If we find the article we're trying to move in the ancestor chain, it's circular
 404                if (currentId.Value == articleId)
 405                {
 406                    return true;
 407                }
 408
 409                // Move up to parent
 410                var parent = await GetAccessibleArticles(userId)
 411                    .AsNoTracking()
 412                    .Where(a => a.Id == currentId.Value)
 413                    .Select(a => new { a.ParentId })
 414                    .FirstOrDefaultAsync();
 415
 416                currentId = parent?.ParentId;
 417            }
 418
 419            return false;
 420        }
 421
 422        /// <summary>
 423        /// Reassigns Session/Acr/Campaign context for a SessionNote subtree when moving between sessions.
 424        /// </summary>
 425        private async Task ReassignSessionContextForSubtreeAsync(
 426            Guid rootArticleId,
 427            Guid targetSessionId,
 428            Guid targetArcId,
 429            Guid targetCampaignId)
 430        {
 431            var queue = new Queue<Guid>();
 432            var visited = new HashSet<Guid>();
 433            queue.Enqueue(rootArticleId);
 434
 435            while (queue.Count > 0)
 436            {
 437                var currentId = queue.Dequeue();
 438                if (!visited.Add(currentId))
 439                {
 440                    continue;
 441                }
 442
 443                var current = await _context.Articles.FirstOrDefaultAsync(a => a.Id == currentId);
 444                if (current == null)
 445                {
 446                    continue;
 447                }
 448
 449                if (current.Type == ArticleType.SessionNote)
 450                {
 451                    current.SessionId = targetSessionId;
 452                    current.ArcId = targetArcId;
 453                    current.CampaignId = targetCampaignId;
 454                }
 455
 456                var childIds = await _context.Articles
 457                    .Where(a => a.ParentId == currentId)
 458                    .Select(a => a.Id)
 459                    .ToListAsync();
 460
 461                foreach (var childId in childIds)
 462                {
 463                    queue.Enqueue(childId);
 464                }
 465            }
 466        }
 467
 468
 469
 470        /// <summary>
 471        /// Get article by hierarchical path.
 472        /// Path format: "world-slug/article-slug/child-slug" (e.g., "stormlight/wiki/characters").
 473        /// The first segment is the world slug, remaining segments are article hierarchy.
 474        /// </summary>
 475        public async Task<ArticleDto?> GetArticleByPathAsync(string path, Guid userId)
 476        {
 477            if (string.IsNullOrWhiteSpace(path))
 478                return null;
 479
 480            var slugs = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
 481            if (slugs.Length == 0)
 482                return null;
 483
 484            // Normal paths are world-scoped ("world-slug/article-slug/..."), but tutorial
 485            // articles are global/system content (WorldId == Guid.Empty) and arrive as
 486            // "tutorial-slug[/child-slug]". Try world resolution first, then tutorial fallback.
 487            var article = await TryResolveWorldArticleByPathAsync(slugs, userId)
 488                ?? await TryResolveTutorialArticleByPathAsync(slugs, userId);
 489
 490            if (article == null && slugs.Length > 1)
 491            {
 492                // Some tutorial URLs include a synthetic "system tutorial world" prefix
 493                // (e.g. /article/system-tutorial/tutorial-article-any). Tutorials are
 494                // stored as global/system articles, so retry after stripping that prefix.
 495                article = await TryResolveTutorialArticleByPathAsync(slugs.Skip(1).ToArray(), userId);
 496            }
 497
 498            if (article == null)
 499            {
 500                _logger.LogWarningSanitized("Article not found for path '{Path}' for user {UserId}", path, userId);
 501            }
 502
 503            return article;
 504        }
 505
 506        /// <summary>
 507        /// Check if a slug is unique among siblings.
 508        /// Session notes (with sessionId) are scoped to (SessionId, Slug).
 509        /// Child articles are scoped to (ParentId, Slug).
 510        /// Root non-session-note articles are scoped to (WorldId, Slug).
 511        /// </summary>
 512        public async Task<bool> IsSlugUniqueAsync(string slug, Guid? parentId, Guid? worldId, Guid userId, Guid? exclude
 513        {
 514            IQueryable<Article> query;
 515
 516            if (articleType == ArticleType.SessionNote && sessionId.HasValue && !parentId.HasValue)
 517            {
 518                // Root session note: unique within (SessionId, Slug)
 519                query = GetAccessibleArticles(userId)
 520                    .AsNoTracking()
 521                    .Where(a => a.Slug == slug &&
 522                                a.Type == ArticleType.SessionNote &&
 523                                a.SessionId == sessionId.Value &&
 524                                a.ParentId == null);
 525            }
 526            else if (parentId.HasValue)
 527            {
 528                // Child article: unique among siblings with same parent
 529                query = GetAccessibleArticles(userId)
 530                    .AsNoTracking()
 531                    .Where(a => a.Slug == slug &&
 532                                a.ParentId == parentId);
 533            }
 534            else
 535            {
 536                // Root article: unique among root articles in the same world
 537                query = GetAccessibleArticles(userId)
 538                    .AsNoTracking()
 539                    .Where(a => a.Slug == slug &&
 540                                a.ParentId == null &&
 541                                a.WorldId == worldId);
 542            }
 543
 544            if (excludeArticleId.HasValue)
 545            {
 546                query = query.Where(a => a.Id != excludeArticleId.Value);
 547            }
 548
 549            return !await query.AnyAsync();
 550        }
 551
 552        /// <summary>
 553        /// Generate a unique slug for an article among its siblings.
 554        /// Session notes (with sessionId) are scoped to (SessionId, Slug).
 555        /// Child articles are scoped to (ParentId, Slug).
 556        /// Root non-session-note articles are scoped to (WorldId, Slug).
 557        /// </summary>
 558        public async Task<string> GenerateUniqueSlugAsync(string title, Guid? parentId, Guid? worldId, Guid userId, Guid
 559        {
 560            var baseSlug = SlugGenerator.GenerateSlug(title);
 561
 562            IQueryable<Article> query;
 563
 564            if (articleType == ArticleType.SessionNote && sessionId.HasValue && !parentId.HasValue)
 565            {
 566                // Root session note: get slugs from session notes in the same session
 567                query = GetAccessibleArticles(userId)
 568                    .AsNoTracking()
 569                    .Where(a => a.Type == ArticleType.SessionNote &&
 570                                a.SessionId == sessionId.Value &&
 571                                a.ParentId == null);
 572            }
 573            else if (parentId.HasValue)
 574            {
 575                // Child article: get slugs from siblings with same parent
 576                query = GetAccessibleArticles(userId)
 577                    .AsNoTracking()
 578                    .Where(a => a.ParentId == parentId);
 579            }
 580            else
 581            {
 582                // Root article: get slugs from root articles in the same world
 583                query = GetAccessibleArticles(userId)
 584                    .AsNoTracking()
 585                    .Where(a => a.ParentId == null && a.WorldId == worldId);
 586            }
 587
 588            var existingSlugs = await query
 589                .Where(a => !excludeArticleId.HasValue || a.Id != excludeArticleId.Value)
 590                .Select(a => a.Slug)
 591                .ToHashSetAsync();
 592
 593            return SlugGenerator.GenerateUniqueSlug(baseSlug, existingSlugs);
 594        }
 595
 596        /// <summary>
 597        /// Build the full hierarchical path for an article, including world slug.
 598        /// Returns format: "world-slug/article-slug/child-slug"
 599        /// </summary>
 600        public async Task<string> BuildArticlePathAsync(Guid articleId, Guid userId)
 601        {
 602            return await _hierarchyService.BuildPathAsync(articleId);
 603        }
 604
 605        public async Task<(Guid ArticleId, IReadOnlyList<(string Slug, string Title)> PathBreadcrumbs)?> ResolveWorldArt
 606            Guid worldId,
 607            IReadOnlyList<string> slugs,
 608            Guid? userId,
 609            CancellationToken cancellationToken = default)
 610        {
 611            if (slugs.Count == 0)
 612                return null;
 613
 614            var breadcrumbs = new List<(string Slug, string Title)>();
 615
 616            Func<string, Guid?, bool, Task<(Guid Id, ArticleType Type)?>> resolveSegment;
 617
 618            if (userId.HasValue)
 619            {
 620                var uid = userId.Value;
 621                resolveSegment = async (slug, parentId, isRootLevel) =>
 622                {
 623                    var query = GetAccessibleArticles(uid)
 624                        .AsNoTracking()
 625                        .Where(a => a.Slug == slug);
 626
 627                    query = isRootLevel
 628                        ? query.Where(a => a.ParentId == null && a.WorldId == worldId)
 629                        : query.Where(a => a.ParentId == parentId);
 630
 631                    var article = await query.Select(a => new { a.Id, a.Type, a.Title }).FirstOrDefaultAsync(cancellatio
 632                    if (article != null)
 633                        breadcrumbs.Add((slug, article.Title ?? slug));
 634
 635                    return article == null ? null : (article.Id, article.Type);
 636                };
 637            }
 638            else
 639            {
 640                resolveSegment = async (slug, parentId, isRootLevel) =>
 641                {
 642                    var query = _context.Articles.AsNoTracking()
 643                        .Where(a => a.Slug == slug
 644                                    && a.Visibility == Chronicis.Shared.Enums.ArticleVisibility.Public
 645                                    && a.WorldId == worldId);
 646
 647                    query = isRootLevel
 648                        ? query.Where(a => a.ParentId == null)
 649                        : query.Where(a => a.ParentId == parentId);
 650
 651                    var article = await query.Select(a => new { a.Id, a.Type, a.Title }).FirstOrDefaultAsync(cancellatio
 652                    if (article != null)
 653                        breadcrumbs.Add((slug, article.Title ?? slug));
 654
 655                    return article == null ? null : (article.Id, article.Type);
 656                };
 657            }
 658
 659            var resolved = await ArticleSlugPathResolver.ResolveAsync(slugs, resolveSegment);
 660            return resolved.HasValue
 661                ? (resolved.Value.Id, (IReadOnlyList<(string, string)>)breadcrumbs)
 662                : null;
 663        }
 664
 665        public async Task<(Guid ArticleId, string Title)?> GetSessionNoteBySlugAsync(
 666            Guid sessionId,
 667            string slug,
 668            Guid? userId,
 669            CancellationToken cancellationToken = default)
 670        {
 671            IQueryable<Article> query = _context.Articles
 672                .AsNoTracking()
 673                .Where(a => a.SessionId == sessionId
 674                            && a.Type == Chronicis.Shared.Enums.ArticleType.SessionNote
 675                            && a.Slug == slug);
 676
 677            if (userId.HasValue)
 678            {
 679                query = _readAccessPolicy.ApplyAuthenticatedWorldArticleFilter(query, userId.Value);
 680            }
 681            else
 682            {
 683                query = _readAccessPolicy.ApplyPublicVisibilityFilter(query);
 684            }
 685
 686            var article = await query
 687                .Select(a => new { a.Id, a.Title })
 688                .FirstOrDefaultAsync(cancellationToken);
 689
 690            return article == null ? null : (article.Id, article.Title ?? slug);
 691        }
 692
 693        public async Task<(Guid ArticleId, string Title)?> GetTutorialBySlugAsync(
 694            string slug,
 695            CancellationToken cancellationToken = default)
 696        {
 697            var article = await _context.Articles
 698                .AsNoTracking()
 699                .Where(a => a.Slug == slug
 700                            && a.Type == Chronicis.Shared.Enums.ArticleType.Tutorial
 701                            && a.WorldId == Guid.Empty)
 702                .Select(a => new { a.Id, a.Title })
 703                .FirstOrDefaultAsync(cancellationToken);
 704
 705            return article == null ? null : (article.Id, article.Title ?? slug);
 706        }
 707
 708        private async Task<ArticleDto?> TryResolveWorldArticleByPathAsync(string[] slugs, Guid userId)
 709        {
 710            if (slugs.Length < 2)
 711            {
 712                return null;
 713            }
 714
 715            var worldSlug = slugs[0];
 716
 717            var world = _context.Worlds
 718                .AsNoTracking();
 719
 720            var resolvedWorld = await _readAccessPolicy
 721                .ApplyAuthenticatedWorldFilter(world, userId)
 722                .Where(w => w.Slug == worldSlug)
 723                .Select(w => new { w.Id })
 724                .FirstOrDefaultAsync();
 725
 726            if (resolvedWorld == null)
 727            {
 728                return null;
 729            }
 730
 731            var resolvedArticle = await ArticleSlugPathResolver.ResolveAsync(
 732                slugs.Skip(1).ToArray(),
 733                async (slug, parentId, isRootLevel) =>
 734                {
 735                    var query = GetAccessibleArticles(userId)
 736                        .AsNoTracking()
 737                        .Where(a => a.Slug == slug);
 738
 739                    query = isRootLevel
 740                        ? query.Where(a => a.ParentId == null && a.WorldId == resolvedWorld.Id)
 741                        : query.Where(a => a.ParentId == parentId);
 742
 743                    var article = await query
 744                        .Select(a => new { a.Id, a.Type })
 745                        .FirstOrDefaultAsync();
 746
 747                    return article == null
 748                        ? null
 749                        : (article.Id, article.Type);
 750                });
 751
 752            return resolvedArticle.HasValue
 753                ? await GetArticleDetailAsync(resolvedArticle.Value.Id, userId)
 754                : null;
 755        }
 756
 757        private async Task<ArticleDto?> TryResolveTutorialArticleByPathAsync(string[] slugs, Guid userId)
 758        {
 759            var resolvedArticle = await ArticleSlugPathResolver.ResolveAsync(
 760                slugs,
 761                async (slug, parentId, isRootLevel) =>
 762                {
 763                    var query = _context.Articles
 764                        .AsNoTracking()
 765                        .Where(a => a.Type == ArticleType.Tutorial &&
 766                                    a.WorldId == Guid.Empty &&
 767                                    a.Slug == slug);
 768
 769                    query = isRootLevel
 770                        ? query.Where(a => a.ParentId == null)
 771                        : query.Where(a => a.ParentId == parentId);
 772
 773                    var article = await query
 774                        .Select(a => new { a.Id, a.Type })
 775                        .FirstOrDefaultAsync();
 776
 777                    return article == null
 778                        ? null
 779                        : (article.Id, article.Type);
 780                });
 781
 782            return resolvedArticle.HasValue
 783                ? await GetArticleDetailAsync(resolvedArticle.Value.Id, userId)
 784                : null;
 785        }
 786    }
 787}