< Summary

Information
Class: Chronicis.Api.Services.PublicWorldService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/PublicWorldService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 313
Coverable lines: 313
Total lines: 520
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 96
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%
GetPublicWorldAsync()0%156120%
GetPublicArticleTreeAsync()0%3906620%
CreateVirtualGroup(...)100%210%
CollectArticleIds(...)0%2040%
GetPublicArticleAsync()0%272160%
GetPublicArticlePathAsync()0%7280%

File(s)

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

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Shared.DTOs;
 3using Chronicis.Shared.Enums;
 4using Chronicis.Shared.Extensions;
 5using Microsoft.EntityFrameworkCore;
 6
 7namespace Chronicis.Api.Services;
 8
 9/// <summary>
 10/// Service for anonymous public access to worlds.
 11/// All methods return only publicly visible content - no authentication required.
 12/// </summary>
 13public class PublicWorldService : IPublicWorldService
 14{
 15    private readonly ChronicisDbContext _context;
 16    private readonly ILogger<PublicWorldService> _logger;
 17    private readonly IArticleHierarchyService _hierarchyService;
 18
 019    public PublicWorldService(ChronicisDbContext context, ILogger<PublicWorldService> logger, IArticleHierarchyService h
 20    {
 021        _context = context;
 022        _logger = logger;
 023        _hierarchyService = hierarchyService;
 024    }
 25
 26    /// <summary>
 27    /// Get a public world by its public slug.
 28    /// Returns null if world doesn't exist or is not public.
 29    /// </summary>
 30    public async Task<WorldDetailDto?> GetPublicWorldAsync(string publicSlug)
 31    {
 032        var normalizedSlug = publicSlug.Trim().ToLowerInvariant();
 33
 034        var world = await _context.Worlds
 035            .AsNoTracking()
 036            .Include(w => w.Owner)
 037            .Include(w => w.Campaigns)
 038            .Where(w => w.PublicSlug == normalizedSlug && w.IsPublic)
 039            .FirstOrDefaultAsync();
 40
 041        if (world == null)
 42        {
 043            _logger.LogDebugSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug);
 044            return null;
 45        }
 46
 047        _logger.LogDebugSanitized("Public world '{WorldName}' accessed via slug '{PublicSlug}'",
 048            world.Name, normalizedSlug);
 49
 050        return new WorldDetailDto
 051        {
 052            Id = world.Id,
 053            Name = world.Name,
 054            Slug = world.Slug,
 055            Description = world.Description,
 056            OwnerId = world.OwnerId,
 057            OwnerName = world.Owner?.DisplayName ?? "Unknown",
 058            CreatedAt = world.CreatedAt,
 059            CampaignCount = world.Campaigns?.Count ?? 0,
 060            IsPublic = world.IsPublic,
 061            PublicSlug = world.PublicSlug,
 062            // Include public campaigns
 063            Campaigns = world.Campaigns?.Select(c => new CampaignDto
 064            {
 065                Id = c.Id,
 066                Name = c.Name,
 067                Description = c.Description,
 068                WorldId = c.WorldId,
 069                IsActive = c.IsActive,
 070                StartedAt = c.StartedAt
 071            }).ToList() ?? new List<CampaignDto>()
 072        };
 073    }
 74
 75    /// <summary>
 76    /// Get the article tree for a public world.
 77    /// Only returns articles with Public visibility.
 78    /// Returns a hierarchical tree structure organized by virtual groups (Campaigns, Characters, Wiki).
 79    /// </summary>
 80    public async Task<List<ArticleTreeDto>> GetPublicArticleTreeAsync(string publicSlug)
 81    {
 082        var normalizedSlug = publicSlug.Trim().ToLowerInvariant();
 83
 84        // First, verify the world exists and is public
 085        var world = await _context.Worlds
 086            .AsNoTracking()
 087            .Include(w => w.Campaigns)
 088                .ThenInclude(c => c.Arcs)
 089            .Where(w => w.PublicSlug == normalizedSlug && w.IsPublic)
 090            .FirstOrDefaultAsync();
 91
 092        if (world == null)
 93        {
 094            _logger.LogDebugSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug);
 095            return new List<ArticleTreeDto>();
 96        }
 97
 98        // Get all public articles for this world
 099        var allPublicArticles = await _context.Articles
 0100            .AsNoTracking()
 0101            .Where(a => a.WorldId == world.Id && a.Visibility == ArticleVisibility.Public)
 0102            .Select(a => new ArticleTreeDto
 0103            {
 0104                Id = a.Id,
 0105                Title = a.Title,
 0106                Slug = a.Slug,
 0107                ParentId = a.ParentId,
 0108                WorldId = a.WorldId,
 0109                CampaignId = a.CampaignId,
 0110                ArcId = a.ArcId,
 0111                Type = a.Type,
 0112                Visibility = a.Visibility,
 0113                HasChildren = false, // Will calculate below
 0114                ChildCount = 0,      // Will calculate below
 0115                Children = new List<ArticleTreeDto>(),
 0116                CreatedAt = a.CreatedAt,
 0117                EffectiveDate = a.EffectiveDate,
 0118                IconEmoji = a.IconEmoji,
 0119                CreatedBy = a.CreatedBy
 0120            })
 0121            .ToListAsync();
 122
 123        // Build article index and children relationships
 0124        var articleIndex = allPublicArticles.ToDictionary(a => a.Id);
 0125        var publicArticleIds = new HashSet<Guid>(allPublicArticles.Select(a => a.Id));
 126
 127        // Link children to parents
 0128        foreach (var article in allPublicArticles)
 129        {
 0130            if (article.ParentId.HasValue && articleIndex.TryGetValue(article.ParentId.Value, out var parent))
 131            {
 0132                parent.Children ??= new List<ArticleTreeDto>();
 0133                parent.Children.Add(article);
 0134                parent.HasChildren = true;
 0135                parent.ChildCount++;
 136            }
 137        }
 138
 139        // Sort children by title
 0140        foreach (var article in allPublicArticles.Where(a => a.Children?.Any() == true))
 141        {
 0142            article.Children = article.Children!.OrderBy(c => c.Title).ToList();
 143        }
 144
 145        // Build virtual groups
 0146        var result = new List<ArticleTreeDto>();
 147
 148        // 1. Campaigns group - contains campaigns with their arcs and sessions
 0149        var campaignsGroup = CreateVirtualGroup("campaigns", "Campaigns", "fa-solid fa-dungeon");
 0150        foreach (var campaign in world.Campaigns?.OrderBy(c => c.Name) ?? Enumerable.Empty<Chronicis.Shared.Models.Campa
 151        {
 0152            var campaignNode = new ArticleTreeDto
 0153            {
 0154                Id = campaign.Id,
 0155                Title = campaign.Name,
 0156                Slug = campaign.Name.ToLowerInvariant().Replace(" ", "-"),
 0157                Type = ArticleType.WikiArticle, // Use WikiArticle as placeholder
 0158                IconEmoji = "fa-solid fa-dungeon",
 0159                Children = new List<ArticleTreeDto>(),
 0160                IsVirtualGroup = true
 0161            };
 162
 163            // Add arcs under campaign
 0164            foreach (var arc in campaign.Arcs?.OrderBy(a => a.SortOrder).ThenBy(a => a.Name) ?? Enumerable.Empty<Chronic
 165            {
 0166                var arcNode = new ArticleTreeDto
 0167                {
 0168                    Id = arc.Id,
 0169                    Title = arc.Name,
 0170                    Slug = arc.Name.ToLowerInvariant().Replace(" ", "-"),
 0171                    Type = ArticleType.WikiArticle,
 0172                    IconEmoji = "fa-solid fa-book-open",
 0173                    Children = new List<ArticleTreeDto>(),
 0174                    IsVirtualGroup = true
 0175                };
 176
 177                // Find session articles for this arc
 0178                var sessionArticles = allPublicArticles
 0179                    .Where(a => a.ArcId == arc.Id && a.Type == ArticleType.Session && a.ParentId == null)
 0180                    .OrderBy(a => a.Title)
 0181                    .ToList();
 182
 0183                foreach (var session in sessionArticles)
 184                {
 0185                    arcNode.Children.Add(session);
 0186                    arcNode.HasChildren = true;
 0187                    arcNode.ChildCount++;
 188                }
 189
 0190                if (arcNode.Children.Any())
 191                {
 0192                    campaignNode.Children.Add(arcNode);
 0193                    campaignNode.HasChildren = true;
 0194                    campaignNode.ChildCount++;
 195                }
 196            }
 197
 0198            if (campaignNode.Children.Any())
 199            {
 0200                campaignsGroup.Children!.Add(campaignNode);
 0201                campaignsGroup.HasChildren = true;
 0202                campaignsGroup.ChildCount++;
 203            }
 204        }
 205
 0206        if (campaignsGroup.Children!.Any())
 207        {
 0208            result.Add(campaignsGroup);
 209        }
 210
 211        // 2. Player Characters group
 0212        var charactersGroup = CreateVirtualGroup("characters", "Player Characters", "fa-solid fa-user-ninja");
 0213        var characterArticles = allPublicArticles
 0214            .Where(a => a.Type == ArticleType.Character && a.ParentId == null)
 0215            .OrderBy(a => a.Title)
 0216            .ToList();
 217
 0218        foreach (var article in characterArticles)
 219        {
 0220            charactersGroup.Children!.Add(article);
 0221            charactersGroup.HasChildren = true;
 0222            charactersGroup.ChildCount++;
 223        }
 224
 0225        if (charactersGroup.Children!.Any())
 226        {
 0227            result.Add(charactersGroup);
 228        }
 229
 230        // 3. Wiki group
 0231        var wikiGroup = CreateVirtualGroup("wiki", "Wiki", "fa-solid fa-book");
 0232        var wikiArticles = allPublicArticles
 0233            .Where(a => a.Type == ArticleType.WikiArticle && a.ParentId == null)
 0234            .OrderBy(a => a.Title)
 0235            .ToList();
 236
 0237        foreach (var article in wikiArticles)
 238        {
 0239            wikiGroup.Children!.Add(article);
 0240            wikiGroup.HasChildren = true;
 0241            wikiGroup.ChildCount++;
 242        }
 243
 0244        if (wikiGroup.Children!.Any())
 245        {
 0246            result.Add(wikiGroup);
 247        }
 248
 249        // 4. Uncategorized (Legacy and other types without parents not already included)
 0250        var includedIds = new HashSet<Guid>();
 251
 252        // Collect all IDs from campaigns/arcs/sessions
 0253        foreach (var campaign in result.FirstOrDefault(r => r.Slug == "campaigns")?.Children ?? new List<ArticleTreeDto>
 254        {
 0255            foreach (var arc in campaign.Children ?? new List<ArticleTreeDto>())
 256            {
 0257                foreach (var session in arc.Children ?? new List<ArticleTreeDto>())
 258                {
 0259                    CollectArticleIds(session, includedIds);
 260                }
 261            }
 262        }
 263
 264        // Collect character and wiki IDs
 0265        foreach (var article in characterArticles)
 266        {
 0267            CollectArticleIds(article, includedIds);
 268        }
 0269        foreach (var article in wikiArticles)
 270        {
 0271            CollectArticleIds(article, includedIds);
 272        }
 273
 0274        var uncategorizedArticles = allPublicArticles
 0275            .Where(a => a.ParentId == null && !includedIds.Contains(a.Id))
 0276            .OrderBy(a => a.Title)
 0277            .ToList();
 278
 0279        if (uncategorizedArticles.Any())
 280        {
 0281            var uncategorizedGroup = CreateVirtualGroup("uncategorized", "Uncategorized", "fa-solid fa-folder");
 0282            foreach (var article in uncategorizedArticles)
 283            {
 0284                uncategorizedGroup.Children!.Add(article);
 0285                uncategorizedGroup.HasChildren = true;
 0286                uncategorizedGroup.ChildCount++;
 287            }
 0288            result.Add(uncategorizedGroup);
 289        }
 290
 0291        _logger.LogDebugSanitized("Retrieved {Count} public articles for world '{PublicSlug}' in {GroupCount} groups",
 0292            allPublicArticles.Count, normalizedSlug, result.Count);
 293
 0294        return result;
 0295    }
 296
 297    private static ArticleTreeDto CreateVirtualGroup(string slug, string title, string icon)
 298    {
 0299        return new ArticleTreeDto
 0300        {
 0301            Id = Guid.NewGuid(), // Virtual ID
 0302            Title = title,
 0303            Slug = slug,
 0304            Type = ArticleType.WikiArticle,
 0305            IconEmoji = icon,
 0306            HasChildren = false,
 0307            ChildCount = 0,
 0308            Children = new List<ArticleTreeDto>(),
 0309            IsVirtualGroup = true
 0310        };
 311    }
 312
 313    private static void CollectArticleIds(ArticleTreeDto article, HashSet<Guid> ids)
 314    {
 0315        ids.Add(article.Id);
 0316        if (article.Children != null)
 317        {
 0318            foreach (var child in article.Children)
 319            {
 0320                CollectArticleIds(child, ids);
 321            }
 322        }
 0323    }
 324
 325    /// <summary>
 326    /// Get a specific article by path in a public world.
 327    /// Returns null if article doesn't exist, world is not public, or article is not Public visibility.
 328    /// Path format: "article-slug/child-slug" (does not include world slug)
 329    /// </summary>
 330    public async Task<ArticleDto?> GetPublicArticleAsync(string publicSlug, string articlePath)
 331    {
 0332        var normalizedSlug = publicSlug.Trim().ToLowerInvariant();
 333
 334        // First, verify the world exists and is public
 0335        var world = await _context.Worlds
 0336            .AsNoTracking()
 0337            .Where(w => w.PublicSlug == normalizedSlug && w.IsPublic)
 0338            .Select(w => new { w.Id, w.Name, w.Slug })
 0339            .FirstOrDefaultAsync();
 340
 0341        if (world == null)
 342        {
 0343            _logger.LogDebugSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug);
 0344            return null;
 345        }
 346
 0347        if (string.IsNullOrWhiteSpace(articlePath))
 348        {
 0349            _logger.LogDebugSanitized("Empty article path for public world '{PublicSlug}'", normalizedSlug);
 0350            return null;
 351        }
 352
 0353        var slugs = articlePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
 0354        if (slugs.Length == 0)
 0355            return null;
 356
 357        // Walk down the tree using slugs
 0358        Guid? currentParentId = null;
 0359        Guid? articleId = null;
 360
 0361        for (int i = 0; i < slugs.Length; i++)
 362        {
 0363            var slug = slugs[i];
 0364            var isRootLevel = (i == 0);
 365
 366            Guid? foundArticleId;
 367
 0368            if (isRootLevel)
 369            {
 370                // Root-level article: filter by WorldId and ParentId = null
 0371                foundArticleId = await _context.Articles
 0372                    .AsNoTracking()
 0373                    .Where(a => a.Slug == slug &&
 0374                                a.ParentId == null &&
 0375                                a.WorldId == world.Id &&
 0376                                a.Visibility == ArticleVisibility.Public)
 0377                    .Select(a => (Guid?)a.Id)
 0378                    .FirstOrDefaultAsync();
 379            }
 380            else
 381            {
 382                // Child article: filter by ParentId
 0383                foundArticleId = await _context.Articles
 0384                    .AsNoTracking()
 0385                    .Where(a => a.Slug == slug &&
 0386                                a.ParentId == currentParentId &&
 0387                                a.Visibility == ArticleVisibility.Public)
 0388                    .Select(a => (Guid?)a.Id)
 0389                    .FirstOrDefaultAsync();
 390            }
 391
 0392            if (!foundArticleId.HasValue)
 393            {
 0394                _logger.LogDebugSanitized("Public article not found for slug '{Slug}' in path '{Path}' for world '{Publi
 0395                    slug, articlePath, normalizedSlug);
 0396                return null;
 397            }
 398
 0399            articleId = foundArticleId.Value;
 0400            currentParentId = foundArticleId.Value;
 0401        }
 402
 403        // Found the article, now get full details
 0404        if (!articleId.HasValue)
 0405            return null;
 406
 0407        var article = await _context.Articles
 0408            .AsNoTracking()
 0409            .Where(a => a.Id == articleId.Value && a.Visibility == ArticleVisibility.Public)
 0410            .Select(a => new ArticleDto
 0411            {
 0412                Id = a.Id,
 0413                Title = a.Title,
 0414                Slug = a.Slug,
 0415                ParentId = a.ParentId,
 0416                WorldId = a.WorldId,
 0417                CampaignId = a.CampaignId,
 0418                ArcId = a.ArcId,
 0419                Body = a.Body ?? string.Empty,
 0420                Type = a.Type,
 0421                Visibility = a.Visibility,
 0422                CreatedAt = a.CreatedAt,
 0423                ModifiedAt = a.ModifiedAt,
 0424                EffectiveDate = a.EffectiveDate,
 0425                CreatedBy = a.CreatedBy,
 0426                LastModifiedBy = a.LastModifiedBy,
 0427                IconEmoji = a.IconEmoji,
 0428                SessionDate = a.SessionDate,
 0429                InGameDate = a.InGameDate,
 0430                PlayerId = a.PlayerId,
 0431                AISummary = a.AISummary,
 0432                AISummaryGeneratedAt = a.AISummaryGeneratedAt,
 0433                Breadcrumbs = new List<BreadcrumbDto>()
 0434            })
 0435            .FirstOrDefaultAsync();
 436
 0437        if (article == null)
 0438            return null;
 439
 440        // Build breadcrumbs (only including public articles) using centralised hierarchy service
 0441        article.Breadcrumbs = await _hierarchyService.BuildBreadcrumbsAsync(articleId.Value, new HierarchyWalkOptions
 0442        {
 0443            PublicOnly = true,
 0444            IncludeWorldBreadcrumb = true,
 0445            IncludeVirtualGroups = true,
 0446            World = new WorldContext { Id = world.Id, Name = world.Name, Slug = world.Slug }
 0447        });
 448
 0449        _logger.LogDebugSanitized("Public article '{Title}' accessed in world '{PublicSlug}'",
 0450            article.Title, normalizedSlug);
 451
 0452        return article;
 0453    }
 454
 455    /// <summary>
 456    /// Resolve an article ID to its public URL path.
 457    /// Returns null if the article doesn't exist, is not public, or doesn't belong to the specified world.
 458    /// </summary>
 459    public async Task<string?> GetPublicArticlePathAsync(string publicSlug, Guid articleId)
 460    {
 0461        var normalizedSlug = publicSlug.Trim().ToLowerInvariant();
 462
 463        // Verify the world exists and is public
 0464        var world = await _context.Worlds
 0465            .AsNoTracking()
 0466            .Where(w => w.PublicSlug == normalizedSlug && w.IsPublic)
 0467            .Select(w => new { w.Id })
 0468            .FirstOrDefaultAsync();
 469
 0470        if (world == null)
 471        {
 0472            _logger.LogDebugSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug);
 0473            return null;
 474        }
 475
 476        // Get the article and verify it's public and belongs to this world
 0477        var article = await _context.Articles
 0478            .AsNoTracking()
 0479            .Where(a => a.Id == articleId &&
 0480                        a.WorldId == world.Id &&
 0481                        a.Visibility == ArticleVisibility.Public)
 0482            .Select(a => new { a.Id, a.Slug, a.ParentId })
 0483            .FirstOrDefaultAsync();
 484
 0485        if (article == null)
 486        {
 0487            _logger.LogDebugSanitized("Public article {ArticleId} not found in world '{PublicSlug}'", articleId, normali
 0488            return null;
 489        }
 490
 491        // Build the path by walking up the parent tree
 0492        var slugs = new List<string> { article.Slug };
 0493        var currentParentId = article.ParentId;
 494
 0495        while (currentParentId.HasValue)
 496        {
 0497            var parentArticle = await _context.Articles
 0498                .AsNoTracking()
 0499                .Where(a => a.Id == currentParentId.Value && a.Visibility == ArticleVisibility.Public)
 0500                .Select(a => new { a.Slug, a.ParentId })
 0501                .FirstOrDefaultAsync();
 502
 0503            if (parentArticle == null)
 504            {
 505                // Parent is not public - this article's path is broken
 0506                _logger.LogDebug("Parent article not public in chain for article {ArticleId}", articleId);
 0507                return null;
 508            }
 509
 0510            slugs.Insert(0, parentArticle.Slug);
 0511            currentParentId = parentArticle.ParentId;
 512        }
 513
 0514        var path = string.Join("/", slugs);
 0515        _logger.LogDebugSanitized("Resolved article {ArticleId} to path '{Path}' in world '{PublicSlug}'",
 0516            articleId, path, normalizedSlug);
 517
 0518        return path;
 0519    }
 520}