< Summary

Information
Class: Chronicis.Api.Controllers.ArticlesController
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Controllers/ArticlesController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 358
Coverable lines: 358
Total lines: 762
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 120
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
GetRootArticles()100%210%
GetAllArticles()100%210%
GetArticleDetail()0%620%
GetArticleChildren()100%210%
GetArticleByPath()0%620%
CreateArticle()0%342180%
UpdateArticle()0%1190340%
DeleteArticle()0%2040%
MoveArticle()0%4260%
UpdateAliases()0%342180%
ParseAliases(...)0%2040%
GetBacklinks()0%2040%
GetOutgoingLinks()0%2040%
ResolveLinks()0%156120%
AutoLink()0%7280%
DeleteArticleAndDescendantsAsync()0%2040%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Controllers/ArticlesController.cs

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Api.Infrastructure;
 3using Chronicis.Api.Services;
 4using Chronicis.Api.Services.Articles;
 5using Chronicis.Shared.DTOs;
 6using Chronicis.Shared.Extensions;
 7using Chronicis.Shared.Models;
 8using Chronicis.Shared.Utilities;
 9using Microsoft.AspNetCore.Authorization;
 10using Microsoft.AspNetCore.Mvc;
 11using Microsoft.EntityFrameworkCore;
 12
 13namespace Chronicis.Api.Controllers;
 14
 15/// <summary>
 16/// API endpoints for Article operations.
 17/// </summary>
 18[ApiController]
 19[Route("articles")]
 20[Authorize]
 21public class ArticlesController : ControllerBase
 22{
 23    private readonly IArticleService _articleService;
 24    private readonly IArticleValidationService _validationService;
 25    private readonly ILinkSyncService _linkSyncService;
 26    private readonly IAutoLinkService _autoLinkService;
 27    private readonly IArticleExternalLinkService _externalLinkService;
 28    private readonly IArticleHierarchyService _hierarchyService;
 29    private readonly ChronicisDbContext _context;
 30    private readonly ICurrentUserService _currentUserService;
 31    private readonly IWorldDocumentService _worldDocumentService;
 32    private readonly ILogger<ArticlesController> _logger;
 33
 034    public ArticlesController(
 035        IArticleService articleService,
 036        IArticleValidationService validationService,
 037        ILinkSyncService linkSyncService,
 038        IAutoLinkService autoLinkService,
 039        IArticleExternalLinkService externalLinkService,
 040        IArticleHierarchyService hierarchyService,
 041        ChronicisDbContext context,
 042        ICurrentUserService currentUserService,
 043        IWorldDocumentService worldDocumentService,
 044        ILogger<ArticlesController> logger)
 45    {
 046        _articleService = articleService;
 047        _validationService = validationService;
 048        _linkSyncService = linkSyncService;
 049        _autoLinkService = autoLinkService;
 050        _externalLinkService = externalLinkService;
 051        _hierarchyService = hierarchyService;
 052        _context = context;
 053        _currentUserService = currentUserService;
 054        _worldDocumentService = worldDocumentService;
 055        _logger = logger;
 056    }
 57
 58    /// <summary>
 59    /// GET /api/articles - Returns all root-level articles (those without a parent).
 60    /// </summary>
 61    [HttpGet]
 62    public async Task<ActionResult<IEnumerable<ArticleTreeDto>>> GetRootArticles([FromQuery] Guid? worldId)
 63    {
 064        var user = await _currentUserService.GetRequiredUserAsync();
 65
 66        try
 67        {
 068            var articles = await _articleService.GetRootArticlesAsync(user.Id, worldId);
 069            return Ok(articles);
 70        }
 071        catch (Exception ex)
 72        {
 073            _logger.LogError(ex, "Error fetching root articles");
 074            return StatusCode(500, "Internal server error");
 75        }
 076    }
 77
 78    /// <summary>
 79    /// GET /api/articles/all - Returns all articles for the current user in a flat list.
 80    /// </summary>
 81    [HttpGet("all")]
 82    public async Task<ActionResult<IEnumerable<ArticleTreeDto>>> GetAllArticles([FromQuery] Guid? worldId)
 83    {
 084        var user = await _currentUserService.GetRequiredUserAsync();
 85
 86        try
 87        {
 088            var articles = await _articleService.GetAllArticlesAsync(user.Id, worldId);
 089            return Ok(articles);
 90        }
 091        catch (Exception ex)
 92        {
 093            _logger.LogError(ex, "Error fetching all articles");
 094            return StatusCode(500, "Internal server error");
 95        }
 096    }
 97
 98    /// <summary>
 99    /// GET /api/articles/{id} - Returns detailed information for a specific article.
 100    /// </summary>
 101    [HttpGet("{id:guid}")]
 102    public async Task<ActionResult<ArticleDto>> GetArticleDetail(Guid id)
 103    {
 0104        var user = await _currentUserService.GetRequiredUserAsync();
 105
 106        try
 107        {
 0108            var article = await _articleService.GetArticleDetailAsync(id, user.Id);
 109
 0110            if (article == null)
 111            {
 0112                return NotFound(new { message = $"Article {id} not found" });
 113            }
 114
 0115            return Ok(article);
 116        }
 0117        catch (Exception ex)
 118        {
 0119            _logger.LogError(ex, "Error fetching article {ArticleId}", id);
 0120            return StatusCode(500, "Internal server error");
 121        }
 0122    }
 123
 124    /// <summary>
 125    /// GET /api/articles/{id}/children - Returns all child articles of the specified parent.
 126    /// </summary>
 127    [HttpGet("{id:guid}/children")]
 128    public async Task<ActionResult<IEnumerable<ArticleTreeDto>>> GetArticleChildren(Guid id)
 129    {
 0130        var user = await _currentUserService.GetRequiredUserAsync();
 131
 132        try
 133        {
 0134            var children = await _articleService.GetChildrenAsync(id, user.Id);
 0135            return Ok(children);
 136        }
 0137        catch (Exception ex)
 138        {
 0139            _logger.LogError(ex, "Error fetching children for article {ParentId}", id);
 0140            return StatusCode(500, "Internal server error");
 141        }
 0142    }
 143
 144    /// <summary>
 145    /// GET /api/articles/by-path/{*path} - Gets an article by its URL path.
 146    /// </summary>
 147    [HttpGet("by-path/{*path}")]
 148    public async Task<ActionResult<ArticleDto>> GetArticleByPath(string path)
 149    {
 0150        var user = await _currentUserService.GetRequiredUserAsync();
 151
 152        try
 153        {
 0154            var article = await _articleService.GetArticleByPathAsync(path, user.Id);
 155
 0156            if (article == null)
 157            {
 0158                return NotFound(new { message = "Article not found" });
 159            }
 160
 0161            return Ok(article);
 162        }
 0163        catch (Exception ex)
 164        {
 0165            _logger.LogErrorSanitized(ex, "Error fetching article by path: {Path}", path);
 0166            return StatusCode(500, "Internal server error");
 167        }
 0168    }
 169
 170    /// <summary>
 171    /// POST /api/articles - Creates a new article.
 172    /// </summary>
 173    [HttpPost]
 174    public async Task<ActionResult<ArticleDto>> CreateArticle([FromBody] ArticleCreateDto dto)
 175    {
 0176        var user = await _currentUserService.GetRequiredUserAsync();
 177
 178        try
 179        {
 0180            if (dto == null)
 181            {
 0182                return BadRequest("Invalid request body");
 183            }
 184
 0185            var validationResult = await _validationService.ValidateCreateAsync(dto);
 0186            if (!validationResult.IsValid)
 187            {
 0188                return BadRequest(new { errors = validationResult.Errors });
 189            }
 190
 191            // Generate slug
 192            string slug;
 0193            if (!string.IsNullOrWhiteSpace(dto.Slug))
 194            {
 0195                if (!SlugGenerator.IsValidSlug(dto.Slug))
 196                {
 0197                    return BadRequest("Slug must contain only lowercase letters, numbers, and hyphens");
 198                }
 199
 0200                if (!await _articleService.IsSlugUniqueAsync(dto.Slug, dto.ParentId, dto.WorldId, user.Id))
 201                {
 0202                    return Conflict($"An article with slug '{dto.Slug}' already exists in this location");
 203                }
 204
 0205                slug = dto.Slug;
 206            }
 207            else
 208            {
 0209                slug = await _articleService.GenerateUniqueSlugAsync(dto.Title, dto.ParentId, dto.WorldId, user.Id);
 210            }
 211
 0212            var article = new Article
 0213            {
 0214                Id = Guid.NewGuid(),
 0215                Title = dto.Title,
 0216                Slug = slug,
 0217                ParentId = dto.ParentId,
 0218                WorldId = dto.WorldId,
 0219                CampaignId = dto.CampaignId,
 0220                ArcId = dto.ArcId,
 0221                Body = dto.Body,
 0222                Type = dto.Type,
 0223                Visibility = dto.Visibility,
 0224                CreatedAt = DateTime.UtcNow,
 0225                CreatedBy = user.Id,
 0226                EffectiveDate = dto.EffectiveDate ?? DateTime.UtcNow,
 0227                IconEmoji = dto.IconEmoji,
 0228                SessionDate = dto.SessionDate,
 0229                InGameDate = dto.InGameDate,
 0230                PlayerId = dto.PlayerId
 0231            };
 232
 0233            _context.Articles.Add(article);
 0234            await _context.SaveChangesAsync();
 235
 236            // Sync wiki links if body contains content
 0237            if (!string.IsNullOrEmpty(dto.Body))
 238            {
 0239                await _linkSyncService.SyncLinksAsync(article.Id, dto.Body);
 240            }
 241
 0242            var responseDto = new ArticleDto
 0243            {
 0244                Id = article.Id,
 0245                Title = article.Title,
 0246                Slug = article.Slug,
 0247                ParentId = article.ParentId,
 0248                WorldId = article.WorldId,
 0249                CampaignId = article.CampaignId,
 0250                ArcId = article.ArcId,
 0251                Body = article.Body ?? string.Empty,
 0252                Type = article.Type,
 0253                Visibility = article.Visibility,
 0254                CreatedAt = article.CreatedAt,
 0255                ModifiedAt = article.ModifiedAt,
 0256                EffectiveDate = article.EffectiveDate,
 0257                CreatedBy = article.CreatedBy,
 0258                IconEmoji = article.IconEmoji,
 0259                HasChildren = false
 0260            };
 261
 0262            return CreatedAtAction(nameof(GetArticleDetail), new { id = article.Id }, responseDto);
 263        }
 0264        catch (Exception ex)
 265        {
 0266            _logger.LogError(ex, "Error creating article");
 0267            return StatusCode(500, $"Error creating article: {ex.Message}");
 268        }
 0269    }
 270
 271    /// <summary>
 272    /// PUT /api/articles/{id} - Updates an existing article.
 273    /// </summary>
 274    [HttpPut("{id:guid}")]
 275    public async Task<ActionResult<ArticleDto>> UpdateArticle(Guid id, [FromBody] ArticleUpdateDto dto)
 276    {
 0277        var user = await _currentUserService.GetRequiredUserAsync();
 278
 279        try
 280        {
 0281            if (dto == null)
 282            {
 0283                return BadRequest("Invalid request body");
 284            }
 285
 0286            var validationResult = await _validationService.ValidateUpdateAsync(id, dto);
 0287            if (!validationResult.IsValid)
 288            {
 0289                return BadRequest(new { errors = validationResult.Errors });
 290            }
 291
 292            // Get article - check user has access via world membership
 0293            var article = await _context.Articles
 0294                .Where(a => a.Id == id)
 0295                .Where(a => a.World != null && a.World.Members.Any(m => m.UserId == user.Id))
 0296                .FirstOrDefaultAsync();
 297
 0298            if (article == null)
 299            {
 0300                return NotFound($"Article {id} not found");
 301            }
 302
 303            // Handle slug update if provided
 0304            if (!string.IsNullOrWhiteSpace(dto.Slug) && dto.Slug != article.Slug)
 305            {
 0306                if (!SlugGenerator.IsValidSlug(dto.Slug))
 307                {
 0308                    return BadRequest("Slug must contain only lowercase letters, numbers, and hyphens");
 309                }
 310
 0311                if (!await _articleService.IsSlugUniqueAsync(dto.Slug, article.ParentId, article.WorldId, user.Id, id))
 312                {
 0313                    return Conflict($"An article with slug '{dto.Slug}' already exists in this location");
 314                }
 315
 0316                article.Slug = dto.Slug;
 317            }
 318
 319            // Update fields
 0320            if (dto.Title != null)
 0321                article.Title = dto.Title;
 0322            if (dto.Body != null)
 0323                article.Body = dto.Body;
 0324            if (dto.EffectiveDate.HasValue)
 0325                article.EffectiveDate = dto.EffectiveDate.Value;
 0326            if (dto.IconEmoji != null)
 0327                article.IconEmoji = dto.IconEmoji;
 0328            if (dto.SessionDate.HasValue)
 0329                article.SessionDate = dto.SessionDate;
 0330            if (dto.InGameDate != null)
 0331                article.InGameDate = dto.InGameDate;
 0332            if (dto.Visibility.HasValue)
 0333                article.Visibility = dto.Visibility.Value;
 0334            if (dto.Type.HasValue)
 0335                article.Type = dto.Type.Value;
 336
 0337            article.ModifiedAt = DateTime.UtcNow;
 0338            article.LastModifiedBy = user.Id;
 339
 0340            await _context.SaveChangesAsync();
 341
 342            // Sync wiki links after update
 0343            if (!string.IsNullOrEmpty(dto.Body))
 344            {
 0345                await _linkSyncService.SyncLinksAsync(id, dto.Body);
 346            }
 347
 348            // Sync external links after update
 0349            await _externalLinkService.SyncExternalLinksAsync(id, dto.Body);
 350
 351            // Return updated article
 0352            var updatedArticle = await _articleService.GetArticleDetailAsync(id, user.Id);
 0353            return Ok(updatedArticle);
 354        }
 0355        catch (Exception ex)
 356        {
 0357            _logger.LogError(ex, "Error updating article {ArticleId}", id);
 0358            return StatusCode(500, $"Error updating article: {ex.Message}");
 359        }
 0360    }
 361
 362    /// <summary>
 363    /// DELETE /api/articles/{id} - Deletes an article and all its children.
 364    /// </summary>
 365    [HttpDelete("{id:guid}")]
 366    public async Task<IActionResult> DeleteArticle(Guid id)
 367    {
 0368        var user = await _currentUserService.GetRequiredUserAsync();
 369
 370        try
 371        {
 372            // Get article - check user has access via world membership
 0373            var article = await _context.Articles
 0374                .Where(a => a.Id == id)
 0375                .Where(a => a.World != null && a.World.Members.Any(m => m.UserId == user.Id))
 0376                .FirstOrDefaultAsync();
 377
 0378            if (article == null)
 379            {
 0380                return NotFound($"Article {id} not found");
 381            }
 382
 383            // Delete all descendants recursively
 0384            await DeleteArticleAndDescendantsAsync(id);
 385
 0386            return NoContent();
 387        }
 0388        catch (Exception ex)
 389        {
 0390            _logger.LogError(ex, "Error deleting article {ArticleId}", id);
 0391            return StatusCode(500, $"Error deleting article: {ex.Message}");
 392        }
 0393    }
 394
 395    /// <summary>
 396    /// PUT /api/articles/{id}/move - Moves an article to a new parent.
 397    /// </summary>
 398    [HttpPut("{id:guid}/move")]
 399    public async Task<IActionResult> MoveArticle(Guid id, [FromBody] ArticleMoveDto dto)
 400    {
 0401        var user = await _currentUserService.GetRequiredUserAsync();
 402
 403        try
 404        {
 0405            if (dto == null)
 406            {
 0407                return BadRequest("Invalid request body");
 408            }
 409
 0410            var (success, errorMessage) = await _articleService.MoveArticleAsync(id, dto.NewParentId, user.Id);
 411
 0412            if (!success)
 413            {
 0414                return BadRequest(errorMessage);
 415            }
 416
 417            // Return the updated article
 0418            var article = await _articleService.GetArticleDetailAsync(id, user.Id);
 0419            return Ok(article);
 420        }
 0421        catch (Exception ex)
 422        {
 0423            _logger.LogError(ex, "Error moving article {ArticleId}", id);
 0424            return StatusCode(500, $"Error moving article: {ex.Message}");
 425        }
 0426    }
 427
 428    #region Aliases
 429
 430    /// <summary>
 431    /// PUT /api/articles/{id}/aliases - Updates all aliases for an article.
 432    /// Accepts a comma-delimited string that replaces all existing aliases.
 433    /// </summary>
 434    [HttpPut("{id:guid}/aliases")]
 435    public async Task<ActionResult<ArticleDto>> UpdateAliases(Guid id, [FromBody] ArticleAliasesUpdateDto dto)
 436    {
 0437        var user = await _currentUserService.GetRequiredUserAsync();
 438
 439        try
 440        {
 0441            if (dto == null)
 442            {
 0443                return BadRequest("Invalid request body");
 444            }
 445
 446            // Get article with existing aliases - check user has access via world membership
 0447            var article = await _context.Articles
 0448                .Include(a => a.Aliases)
 0449                .Where(a => a.Id == id)
 0450                .Where(a => a.World != null && a.World.Members.Any(m => m.UserId == user.Id))
 0451                .FirstOrDefaultAsync();
 452
 0453            if (article == null)
 454            {
 0455                return NotFound($"Article {id} not found");
 456            }
 457
 458            // Parse the comma-delimited aliases
 0459            var newAliases = ParseAliases(dto.Aliases);
 460
 461            // Validate: aliases cannot match the article's own title
 0462            var titleLower = article.Title?.ToLowerInvariant() ?? string.Empty;
 0463            var invalidAliases = newAliases.Where(a => a.ToLowerInvariant() == titleLower).ToList();
 0464            if (invalidAliases.Any())
 465            {
 0466                return BadRequest($"Alias cannot match the article's title: {string.Join(", ", invalidAliases)}");
 467            }
 468
 469            // Remove aliases that are no longer in the list
 0470            var aliasesToRemove = article.Aliases
 0471                .Where(existing => !newAliases.Contains(existing.AliasText, StringComparer.OrdinalIgnoreCase))
 0472                .ToList();
 0473            foreach (var alias in aliasesToRemove)
 474            {
 0475                _context.ArticleAliases.Remove(alias);
 476            }
 477
 478            // Add new aliases that don't already exist
 0479            var existingAliasTexts = article.Aliases
 480                .Select(a => a.AliasText.ToLowerInvariant())
 0481                .ToHashSet();
 482
 0483            foreach (var aliasText in newAliases)
 484            {
 0485                if (!existingAliasTexts.Contains(aliasText.ToLowerInvariant()))
 486                {
 0487                    var newAlias = new ArticleAlias
 0488                    {
 0489                        Id = Guid.NewGuid(),
 0490                        ArticleId = id,
 0491                        AliasText = aliasText,
 0492                        CreatedAt = DateTime.UtcNow
 0493                    };
 0494                    _context.ArticleAliases.Add(newAlias);
 495                }
 496            }
 497
 0498            article.ModifiedAt = DateTime.UtcNow;
 0499            article.LastModifiedBy = user.Id;
 500
 0501            await _context.SaveChangesAsync();
 502
 503            // Return updated article with aliases
 0504            var updatedArticle = await _articleService.GetArticleDetailAsync(id, user.Id);
 0505            return Ok(updatedArticle);
 506        }
 0507        catch (Exception ex)
 508        {
 0509            _logger.LogError(ex, "Error updating aliases for article {ArticleId}", id);
 0510            return StatusCode(500, $"Error updating aliases: {ex.Message}");
 511        }
 0512    }
 513
 514    /// <summary>
 515    /// Parses a comma-delimited string into a list of trimmed, non-empty, unique aliases.
 516    /// </summary>
 517    private static List<string> ParseAliases(string? aliasesString)
 518    {
 0519        if (string.IsNullOrWhiteSpace(aliasesString))
 520        {
 0521            return new List<string>();
 522        }
 523
 0524        return aliasesString
 0525            .Split(',', StringSplitOptions.RemoveEmptyEntries)
 0526            .Select(a => a.Trim())
 0527            .Where(a => !string.IsNullOrWhiteSpace(a) && a.Length <= 200)
 0528            .Distinct(StringComparer.OrdinalIgnoreCase)
 0529            .ToList();
 530    }
 531
 532    #endregion
 533
 534    #region Wiki Links
 535
 536    /// <summary>
 537    /// GET /articles/{id}/backlinks - Gets all articles that link to this article.
 538    /// </summary>
 539    [HttpGet("{id:guid}/backlinks")]
 540    public async Task<ActionResult<BacklinksResponseDto>> GetBacklinks(Guid id)
 541    {
 0542        var user = await _currentUserService.GetRequiredUserAsync();
 0543        _logger.LogDebug("Getting backlinks for article {ArticleId}", id);
 544
 545        // Verify article exists and user has access
 0546        var article = await _context.Articles
 0547            .Where(a => a.Id == id)
 0548            .Where(a => a.World != null && a.World.Members.Any(m => m.UserId == user.Id))
 0549            .FirstOrDefaultAsync();
 550
 0551        if (article == null)
 552        {
 0553            return NotFound(new { error = "Article not found or access denied" });
 554        }
 555
 556        // Get all articles that link TO this article
 0557        var backlinks = await _context.ArticleLinks
 0558            .Where(l => l.TargetArticleId == id)
 0559            .Select(l => new BacklinkDto
 0560            {
 0561                ArticleId = l.SourceArticleId,
 0562                Title = l.SourceArticle.Title ?? "Untitled",
 0563                Slug = l.SourceArticle.Slug,
 0564                Snippet = l.DisplayText,
 0565                DisplayPath = ""
 0566            })
 0567            .Distinct()
 0568            .ToListAsync();
 569
 570        // Build display paths using centralised hierarchy service
 0571        foreach (var backlink in backlinks)
 572        {
 0573            backlink.DisplayPath = await _hierarchyService.BuildDisplayPathAsync(backlink.ArticleId);
 574        }
 575
 0576        return Ok(new BacklinksResponseDto { Backlinks = backlinks });
 0577    }
 578
 579    /// <summary>
 580    /// GET /articles/{id}/outgoing-links - Gets all articles that this article links to.
 581    /// </summary>
 582    [HttpGet("{id:guid}/outgoing-links")]
 583    public async Task<ActionResult<BacklinksResponseDto>> GetOutgoingLinks(Guid id)
 584    {
 0585        var user = await _currentUserService.GetRequiredUserAsync();
 0586        _logger.LogDebug("Getting outgoing links for article {ArticleId}", id);
 587
 588        // Verify article exists and user has access
 0589        var article = await _context.Articles
 0590            .Where(a => a.Id == id)
 0591            .Where(a => a.World != null && a.World.Members.Any(m => m.UserId == user.Id))
 0592            .FirstOrDefaultAsync();
 593
 0594        if (article == null)
 595        {
 0596            return NotFound(new { error = "Article not found or access denied" });
 597        }
 598
 599        // Get all articles that this article links TO
 0600        var outgoingLinks = await _context.ArticleLinks
 0601            .Where(l => l.SourceArticleId == id)
 0602            .Select(l => new BacklinkDto
 0603            {
 0604                ArticleId = l.TargetArticleId,
 0605                Title = l.TargetArticle.Title ?? "Untitled",
 0606                Slug = l.TargetArticle.Slug,
 0607                Snippet = l.DisplayText,
 0608                DisplayPath = ""
 0609            })
 0610            .Distinct()
 0611            .ToListAsync();
 612
 613        // Build display paths using centralised hierarchy service
 0614        foreach (var link in outgoingLinks)
 615        {
 0616            link.DisplayPath = await _hierarchyService.BuildDisplayPathAsync(link.ArticleId);
 617        }
 618
 0619        return Ok(new BacklinksResponseDto { Backlinks = outgoingLinks });
 0620    }
 621
 622    /// <summary>
 623    /// POST /articles/resolve-links - Resolves multiple article IDs to check if they exist.
 624    /// </summary>
 625    [HttpPost("resolve-links")]
 626    public async Task<ActionResult<LinkResolutionResponseDto>> ResolveLinks([FromBody] LinkResolutionRequestDto request)
 627    {
 0628        var user = await _currentUserService.GetRequiredUserAsync();
 629
 0630        if (request?.ArticleIds == null || !request.ArticleIds.Any())
 631        {
 0632            return Ok(new LinkResolutionResponseDto { Articles = new Dictionary<Guid, ResolvedLinkDto>() });
 633        }
 634
 0635        _logger.LogDebug("Resolving {Count} article links", request.ArticleIds.Count);
 636
 637        // Get all requested articles that the user has access to
 0638        var articles = await _context.Articles
 0639            .Where(a => request.ArticleIds.Contains(a.Id))
 0640            .Where(a => a.World != null && a.World.Members.Any(m => m.UserId == user.Id))
 0641            .Select(a => new ResolvedLinkDto
 0642            {
 0643                ArticleId = a.Id,
 0644                Exists = true,
 0645                Title = a.Title,
 0646                Slug = a.Slug
 0647            })
 0648            .ToListAsync();
 649
 650        // Build response dictionary
 0651        var result = new LinkResolutionResponseDto
 0652        {
 0653            Articles = new Dictionary<Guid, ResolvedLinkDto>()
 0654        };
 655
 656        // Add found articles
 0657        foreach (var article in articles)
 658        {
 0659            result.Articles[article.ArticleId] = article;
 660        }
 661
 662        // Add missing articles as non-existent
 0663        foreach (var requestedId in request.ArticleIds)
 664        {
 0665            if (!result.Articles.ContainsKey(requestedId))
 666            {
 0667                result.Articles[requestedId] = new ResolvedLinkDto
 0668                {
 0669                    ArticleId = requestedId,
 0670                    Exists = false,
 0671                    Title = null,
 0672                    Slug = null
 0673                };
 674            }
 675        }
 676
 0677        return Ok(result);
 0678    }
 679
 680    /// <summary>
 681    /// POST /articles/{id}/auto-link - Scans article content and returns match positions for wiki links.
 682    /// </summary>
 683    [HttpPost("{id:guid}/auto-link")]
 684    public async Task<ActionResult<AutoLinkResponseDto>> AutoLink(Guid id, [FromBody] AutoLinkRequestDto request)
 685    {
 0686        var user = await _currentUserService.GetRequiredUserAsync();
 687
 0688        if (request == null || string.IsNullOrEmpty(request.Body))
 689        {
 0690            return BadRequest(new { error = "Body content is required" });
 691        }
 692
 0693        _logger.LogDebug("Auto-linking article {ArticleId}", id);
 694
 695        // Get article and verify access
 0696        var article = await _context.Articles
 0697            .Where(a => a.Id == id)
 0698            .Where(a => a.World != null && a.World.Members.Any(m => m.UserId == user.Id))
 0699            .Select(a => new { a.Id, a.WorldId })
 0700            .FirstOrDefaultAsync();
 701
 0702        if (article == null)
 703        {
 0704            return NotFound(new { error = "Article not found or access denied" });
 705        }
 706
 0707        if (!article.WorldId.HasValue)
 708        {
 0709            return BadRequest(new { error = "Article must belong to a world" });
 710        }
 711
 0712        var result = await _autoLinkService.FindLinksAsync(
 0713            id,
 0714            article.WorldId.Value,
 0715            request.Body,
 0716            user.Id);
 717
 0718        return Ok(result);
 0719    }
 720
 721    #endregion
 722
 723    #region Private Helpers
 724
 725    /// <summary>
 726    /// Recursively deletes an article and all its descendants.
 727    /// </summary>
 728    private async Task DeleteArticleAndDescendantsAsync(Guid articleId)
 729    {
 730        // Get all children
 0731        var children = await _context.Articles
 0732            .Where(a => a.ParentId == articleId)
 0733            .Select(a => a.Id)
 0734            .ToListAsync();
 735
 736        // Recursively delete children first
 0737        foreach (var childId in children)
 738        {
 0739            await DeleteArticleAndDescendantsAsync(childId);
 740        }
 741
 742        // Delete article links pointing to/from this article
 0743        var linksToDelete = await _context.ArticleLinks
 0744            .Where(l => l.SourceArticleId == articleId || l.TargetArticleId == articleId)
 0745            .ToListAsync();
 0746        _context.ArticleLinks.RemoveRange(linksToDelete);
 747
 748        // Delete inline images associated with this article
 0749        await _worldDocumentService.DeleteArticleImagesAsync(articleId);
 750
 751        // Delete the article itself
 0752        var article = await _context.Articles.FindAsync(articleId);
 0753        if (article != null)
 754        {
 0755            _context.Articles.Remove(article);
 756        }
 757
 0758        await _context.SaveChangesAsync();
 0759    }
 760
 761    #endregion
 762}