< 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
97%
Covered lines: 252
Uncovered lines: 6
Coverable lines: 258
Total lines: 476
Line coverage: 97.6%
Branch coverage
88%
Covered branches: 46
Total branches: 52
Branch coverage: 88.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetAccessibleArticles(...)100%11100%
GetRootArticlesAsync()100%22100%
GetAllArticlesAsync()100%22100%
GetChildrenAsync()100%11100%
GetArticleDetailAsync()100%22100%
MoveArticleAsync()92.85%1414100%
WouldCreateCircularReferenceAsync()80%101089.47%
GetArticleByPathAsync()87.5%161697.77%
IsSlugUniqueAsync()100%44100%
GenerateUniqueSlugAsync()50%2278.57%
BuildArticlePathAsync()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.Extensions;
 5using Chronicis.Shared.Models;
 6using Chronicis.Shared.Utilities;
 7using Microsoft.EntityFrameworkCore;
 8
 9namespace Chronicis.Api.Services
 10{
 11    public class ArticleService : IArticleService
 12    {
 13        private readonly ChronicisDbContext _context;
 14        private readonly ILogger<ArticleService> _logger;
 15        private readonly IArticleHierarchyService _hierarchyService;
 16
 4017        public ArticleService(ChronicisDbContext context, ILogger<ArticleService> logger, IArticleHierarchyService hiera
 18        {
 4019            _context = context;
 4020            _logger = logger;
 4021            _hierarchyService = hierarchyService;
 4022        }
 23
 24        /// <summary>
 25        /// Get all articles the user has access to via WorldMembers.
 26        /// Private articles are only visible to their creator.
 27        /// This is the base query for all article access - use this instead of filtering by CreatedBy.
 28        /// </summary>
 29        private IQueryable<Article> GetAccessibleArticles(Guid userId)
 30        {
 4631            return from a in _context.Articles
 4632                   join wm in _context.WorldMembers on a.WorldId equals wm.WorldId
 4633                   where wm.UserId == userId
 4634                   // Private articles only visible to creator
 4635                   where a.Visibility != ArticleVisibility.Private || a.CreatedBy == userId
 4636                   select a;
 37        }
 38
 39        /// <summary>
 40        /// Get all root-level articles (ParentId is null) for worlds the user has access to.
 41        /// Optionally filter by WorldId.
 42        /// </summary>
 43        public async Task<List<ArticleTreeDto>> GetRootArticlesAsync(Guid userId, Guid? worldId = null)
 44        {
 545            var query = GetAccessibleArticles(userId)
 546                .AsNoTracking()
 547                .Where(a => a.ParentId == null);
 48
 549            if (worldId.HasValue)
 50            {
 151                query = query.Where(a => a.WorldId == worldId.Value);
 52            }
 53
 554            var rootArticles = await query
 555                .Select(a => new ArticleTreeDto
 556                {
 557                    Id = a.Id,
 558                    Title = a.Title,
 559                    Slug = a.Slug,
 560                    ParentId = a.ParentId,
 561                    WorldId = a.WorldId,
 562                    CampaignId = a.CampaignId,
 563                    ArcId = a.ArcId,
 564                    Type = a.Type,
 565                    Visibility = a.Visibility,
 566                    HasChildren = _context.Articles.Any(c => c.ParentId == a.Id),
 567                    ChildCount = _context.Articles.Count(c => c.ParentId == a.Id),
 568                    Children = new List<ArticleTreeDto>(),
 569                    CreatedAt = a.CreatedAt,
 570                    EffectiveDate = a.EffectiveDate,
 571                    IconEmoji = a.IconEmoji,
 572                    CreatedBy = a.CreatedBy,
 573                    HasAISummary = a.AISummary != null
 574                })
 575                .OrderBy(a => a.Title)
 576                .ToListAsync();
 77
 578            return rootArticles;
 579        }
 80
 81        /// <summary>
 82        /// Get all articles for worlds the user has access to, in a flat list (no hierarchy).
 83        /// Useful for dropdowns, linking dialogs, etc.
 84        /// Optionally filter by WorldId.
 85        /// </summary>
 86        public async Task<List<ArticleTreeDto>> GetAllArticlesAsync(Guid userId, Guid? worldId = null)
 87        {
 388            var query = GetAccessibleArticles(userId).AsNoTracking();
 89
 390            if (worldId.HasValue)
 91            {
 192                query = query.Where(a => a.WorldId == worldId.Value);
 93            }
 94
 395            var articles = await query
 396                .Select(a => new ArticleTreeDto
 397                {
 398                    Id = a.Id,
 399                    Title = a.Title,
 3100                    Slug = a.Slug,
 3101                    ParentId = a.ParentId,
 3102                    WorldId = a.WorldId,
 3103                    CampaignId = a.CampaignId,
 3104                    ArcId = a.ArcId,
 3105                    Type = a.Type,
 3106                    Visibility = a.Visibility,
 3107                    HasChildren = false, // Not relevant in flat list
 3108                    ChildCount = 0,      // Not relevant in flat list
 3109                    Children = new List<ArticleTreeDto>(),
 3110                    CreatedAt = a.CreatedAt,
 3111                    EffectiveDate = a.EffectiveDate,
 3112                    IconEmoji = a.IconEmoji,
 3113                    CreatedBy = a.CreatedBy,
 3114                    HasAISummary = a.AISummary != null
 3115                    // Note: Aliases intentionally not loaded here - loaded separately via GetLinkSuggestions
 3116                })
 3117                .OrderBy(a => a.Title)
 3118                .ToListAsync();
 119
 3120            return articles;
 3121        }
 122
 123        /// <summary>
 124        /// Get all child articles of a specific parent.
 125        /// User must have access to the article's world via WorldMembers.
 126        /// </summary>
 127        public async Task<List<ArticleTreeDto>> GetChildrenAsync(Guid parentId, Guid userId)
 128        {
 3129            var children = await GetAccessibleArticles(userId)
 3130                .AsNoTracking()
 3131                .Where(a => a.ParentId == parentId)
 3132                .Select(a => new ArticleTreeDto
 3133                {
 3134                    Id = a.Id,
 3135                    Title = a.Title,
 3136                    Slug = a.Slug,
 3137                    ParentId = a.ParentId,
 3138                    WorldId = a.WorldId,
 3139                    CampaignId = a.CampaignId,
 3140                    ArcId = a.ArcId,
 3141                    Type = a.Type,
 3142                    Visibility = a.Visibility,
 3143                    HasChildren = _context.Articles.Any(c => c.ParentId == a.Id),
 3144                    ChildCount = _context.Articles.Count(c => c.ParentId == a.Id),
 3145                    Children = new List<ArticleTreeDto>(),
 3146                    CreatedAt = a.CreatedAt,
 3147                    EffectiveDate = a.EffectiveDate,
 3148                    IconEmoji = a.IconEmoji,
 3149                    CreatedBy = a.CreatedBy,
 3150                    HasAISummary = a.AISummary != null
 3151                })
 3152                .OrderBy(a => a.Title)
 3153                .ToListAsync();
 154
 3155            return children;
 3156        }
 157
 158        /// <summary>
 159        /// Get full article details including breadcrumb path from root.
 160        /// User must have access to the article's world via WorldMembers.
 161        /// </summary>
 162        public async Task<ArticleDto?> GetArticleDetailAsync(Guid id, Guid userId)
 163        {
 7164            var article = await GetAccessibleArticles(userId)
 7165                .AsNoTracking()
 7166                .Where(a => a.Id == id)
 7167                .Select(a => new ArticleDto
 7168                {
 7169                    Id = a.Id,
 7170                    Title = a.Title,
 7171                    Slug = a.Slug,
 7172                    ParentId = a.ParentId,
 7173                    WorldId = a.WorldId,
 7174                    CampaignId = a.CampaignId,
 7175                    ArcId = a.ArcId,
 7176                    Body = a.Body ?? string.Empty,
 7177                    Type = a.Type,
 7178                    Visibility = a.Visibility,
 7179                    CreatedAt = a.CreatedAt,
 7180                    ModifiedAt = a.ModifiedAt,
 7181                    EffectiveDate = a.EffectiveDate,
 7182                    CreatedBy = a.CreatedBy,
 7183                    LastModifiedBy = a.LastModifiedBy,
 7184                    IconEmoji = a.IconEmoji,
 7185                    SessionDate = a.SessionDate,
 7186                    InGameDate = a.InGameDate,
 7187                    PlayerId = a.PlayerId,
 7188                    AISummary = a.AISummary,
 7189                    AISummaryGeneratedAt = a.AISummaryGeneratedAt,
 7190                    Breadcrumbs = new List<BreadcrumbDto>(),  // Will populate separately
 7191                    Aliases = a.Aliases.Select(al => new ArticleAliasDto
 7192                    {
 7193                        Id = al.Id,
 7194                        AliasText = al.AliasText,
 7195                        AliasType = al.AliasType,
 7196                        EffectiveDate = al.EffectiveDate,
 7197                        CreatedAt = al.CreatedAt
 7198                    }).ToList()
 7199                })
 7200                .FirstOrDefaultAsync();
 201
 7202            if (article == null)
 203            {
 3204                _logger.LogWarning("Article {ArticleId} not found", id);
 3205                return null;
 206            }
 207
 208            // Build breadcrumb path using centralised hierarchy service
 4209            article.Breadcrumbs = await _hierarchyService.BuildBreadcrumbsAsync(id);
 210
 4211            return article;
 7212        }
 213
 214        /// <summary>
 215        /// Move an article to a new parent (or to root if newParentId is null).
 216        /// </summary>
 217        public async Task<(bool Success, string? ErrorMessage)> MoveArticleAsync(Guid articleId, Guid? newParentId, Guid
 218        {
 219            // 1. Get the article to move (must be in a world user has access to)
 8220            var article = await GetAccessibleArticles(userId)
 8221                .FirstOrDefaultAsync(a => a.Id == articleId);
 222
 8223            if (article == null)
 224            {
 1225                _logger.LogWarning("Article {ArticleId} not found for user {UserId}", articleId, userId);
 1226                return (false, "Article not found");
 227            }
 228
 229            // 2. If moving to same parent, nothing to do
 7230            if (article.ParentId == newParentId)
 231            {
 1232                return (true, null);
 233            }
 234
 235            // 3. If newParentId is specified, validate the target exists and user has access
 6236            if (newParentId.HasValue)
 237            {
 4238                var targetParent = await GetAccessibleArticles(userId)
 4239                    .AsNoTracking()
 4240                    .FirstOrDefaultAsync(a => a.Id == newParentId.Value);
 241
 4242                if (targetParent == null)
 243                {
 1244                    _logger.LogWarning("Target parent {NewParentId} not found for user {UserId}", newParentId, userId);
 1245                    return (false, "Target parent article not found");
 246                }
 247
 248                // 4. Check for circular reference - cannot move an article to be a child of itself or its descendants
 3249                if (await WouldCreateCircularReferenceAsync(articleId, newParentId.Value, userId))
 250                {
 2251                    _logger.LogWarning("Moving article {ArticleId} to {NewParentId} would create circular reference",
 2252                        articleId, newParentId);
 2253                    return (false, "Cannot move an article to be a child of itself or its descendants");
 254                }
 255            }
 256
 257            // 5. Perform the move
 3258            article.ParentId = newParentId;
 3259            article.ModifiedAt = DateTime.UtcNow;
 3260            article.LastModifiedBy = userId;
 261
 3262            await _context.SaveChangesAsync();
 263
 3264            return (true, null);
 8265        }
 266
 267        /// <summary>
 268        /// Check if moving articleId to become a child of targetParentId would create a circular reference.
 269        /// </summary>
 270        private async Task<bool> WouldCreateCircularReferenceAsync(Guid articleId, Guid targetParentId, Guid userId)
 271        {
 272            // If trying to move to self, that's circular
 3273            if (articleId == targetParentId)
 274            {
 1275                return true;
 276            }
 277
 278            // Walk up from targetParentId to root, checking if we encounter articleId
 2279            var currentId = (Guid?)targetParentId;
 2280            var visited = new HashSet<Guid>();
 281
 5282            while (currentId.HasValue)
 283            {
 284                // Prevent infinite loops (shouldn't happen with valid data, but safety first)
 4285                if (visited.Contains(currentId.Value))
 286                {
 0287                    _logger.LogError("Detected existing circular reference in hierarchy at article {ArticleId}", current
 0288                    return true;
 289                }
 4290                visited.Add(currentId.Value);
 291
 292                // If we find the article we're trying to move in the ancestor chain, it's circular
 4293                if (currentId.Value == articleId)
 294                {
 1295                    return true;
 296                }
 297
 298                // Move up to parent
 3299                var parent = await GetAccessibleArticles(userId)
 3300                    .AsNoTracking()
 3301                    .Where(a => a.Id == currentId.Value)
 3302                    .Select(a => new { a.ParentId })
 3303                    .FirstOrDefaultAsync();
 304
 3305                currentId = parent?.ParentId;
 306            }
 307
 1308            return false;
 3309        }
 310
 311
 312
 313        /// <summary>
 314        /// Get article by hierarchical path.
 315        /// Path format: "world-slug/article-slug/child-slug" (e.g., "stormlight/wiki/characters").
 316        /// The first segment is the world slug, remaining segments are article hierarchy.
 317        /// </summary>
 318        public async Task<ArticleDto?> GetArticleByPathAsync(string path, Guid userId)
 319        {
 7320            if (string.IsNullOrWhiteSpace(path))
 1321                return null;
 322
 6323            var slugs = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
 6324            if (slugs.Length == 0)
 0325                return null;
 326
 327            // First segment is the world slug
 6328            var worldSlug = slugs[0];
 329
 330            // Look up the world by slug - user must be a member of the world
 6331            var world = await _context.Worlds
 6332                .AsNoTracking()
 6333                .Where(w => w.Slug == worldSlug && w.Members.Any(m => m.UserId == userId))
 6334                .Select(w => new { w.Id })
 6335                .FirstOrDefaultAsync();
 336
 6337            if (world == null)
 338            {
 2339                _logger.LogWarningSanitized("World not found for slug '{WorldSlug}' or user {UserId} doesn't have access
 2340                return null;
 341            }
 342
 343            // If only world slug provided, no article to return
 4344            if (slugs.Length == 1)
 345            {
 1346                _logger.LogWarningSanitized("Path '{Path}' contains only world slug, no article path", path);
 1347                return null;
 348            }
 349
 350            // Remaining segments are the article path within the world
 3351            Guid? currentParentId = null;
 3352            Guid? articleId = null;
 353
 354            // Walk down the tree using slugs (starting from index 1, skipping world slug)
 14355            for (int i = 1; i < slugs.Length; i++)
 356            {
 5357                var slug = slugs[i];
 5358                var isRootLevel = (i == 1); // First article slug (index 1) is at root level
 359
 360                Article? article;
 5361                if (isRootLevel)
 362                {
 363                    // Root-level article: filter by WorldId and ParentId = null
 3364                    article = await GetAccessibleArticles(userId)
 3365                        .AsNoTracking()
 3366                        .Where(a => a.Slug == slug &&
 3367                                    a.ParentId == null &&
 3368                                    a.WorldId == world.Id)
 3369                        .FirstOrDefaultAsync();
 370                }
 371                else
 372                {
 373                    // Child article: filter by ParentId
 2374                    article = await GetAccessibleArticles(userId)
 2375                        .AsNoTracking()
 2376                        .Where(a => a.Slug == slug &&
 2377                                    a.ParentId == currentParentId)
 2378                        .FirstOrDefaultAsync();
 379                }
 380
 5381                if (article == null)
 382                {
 1383                    _logger.LogWarningSanitized("Article not found for slug '{Slug}' under parent {ParentId} in world {W
 1384                        slug, currentParentId, world.Id, userId);
 1385                    return null;
 386                }
 387
 4388                articleId = article.Id;
 4389                currentParentId = article.Id; // Next iteration looks for children of this article
 4390            }
 391
 392            // Found the article, now get full details
 2393            return articleId.HasValue
 2394                ? await GetArticleDetailAsync(articleId.Value, userId)
 2395                : null;
 7396        }
 397
 398        /// <summary>
 399        /// Check if a slug is unique among siblings.
 400        /// For root articles (ParentId is null), checks uniqueness within the World.
 401        /// For child articles, checks uniqueness within the same parent.
 402        /// </summary>
 403        public async Task<bool> IsSlugUniqueAsync(string slug, Guid? parentId, Guid? worldId, Guid userId, Guid? exclude
 404        {
 405            IQueryable<Article> query;
 406
 5407            if (parentId.HasValue)
 408            {
 409                // Child article: unique among siblings with same parent
 2410                query = GetAccessibleArticles(userId)
 2411                    .AsNoTracking()
 2412                    .Where(a => a.Slug == slug &&
 2413                                a.ParentId == parentId);
 414            }
 415            else
 416            {
 417                // Root article: unique among root articles in the same world
 3418                query = GetAccessibleArticles(userId)
 3419                    .AsNoTracking()
 3420                    .Where(a => a.Slug == slug &&
 3421                                a.ParentId == null &&
 3422                                a.WorldId == worldId);
 423            }
 424
 5425            if (excludeArticleId.HasValue)
 426            {
 1427                query = query.Where(a => a.Id != excludeArticleId.Value);
 428            }
 429
 5430            return !await query.AnyAsync();
 5431        }
 432
 433        /// <summary>
 434        /// Generate a unique slug for an article among its siblings.
 435        /// For root articles (ParentId is null), checks uniqueness within the World.
 436        /// For child articles, checks uniqueness within the same parent.
 437        /// </summary>
 438        public async Task<string> GenerateUniqueSlugAsync(string title, Guid? parentId, Guid? worldId, Guid userId, Guid
 439        {
 3440            var baseSlug = SlugGenerator.GenerateSlug(title);
 441
 442            IQueryable<Article> query;
 443
 3444            if (parentId.HasValue)
 445            {
 446                // Child article: get slugs from siblings with same parent
 0447                query = GetAccessibleArticles(userId)
 0448                    .AsNoTracking()
 0449                    .Where(a => a.ParentId == parentId);
 450            }
 451            else
 452            {
 453                // Root article: get slugs from root articles in the same world
 3454                query = GetAccessibleArticles(userId)
 3455                    .AsNoTracking()
 3456                    .Where(a => a.ParentId == null && a.WorldId == worldId);
 457            }
 458
 3459            var existingSlugs = await query
 3460                .Where(a => !excludeArticleId.HasValue || a.Id != excludeArticleId.Value)
 3461                .Select(a => a.Slug)
 3462                .ToHashSetAsync();
 463
 3464            return SlugGenerator.GenerateUniqueSlug(baseSlug, existingSlugs);
 3465        }
 466
 467        /// <summary>
 468        /// Build the full hierarchical path for an article, including world slug.
 469        /// Returns format: "world-slug/article-slug/child-slug"
 470        /// </summary>
 471        public async Task<string> BuildArticlePathAsync(Guid articleId, Guid userId)
 472        {
 1473            return await _hierarchyService.BuildPathAsync(articleId);
 1474        }
 475    }
 476}