< 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
100%
Covered lines: 37
Uncovered lines: 0
Coverable lines: 37
Total lines: 798
Line coverage: 100%
Branch coverage
100%
Covered branches: 6
Total branches: 6
Branch coverage: 100%
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%
GetRootSessionNotesForSession(...)100%22100%
CreateVirtualGroup(...)100%11100%
CollectArticleIds(...)100%44100%
GetPublicLinkedArticleTitlesAsync(...)100%11100%

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.DTOs.Maps;
 4using Chronicis.Shared.Enums;
 5using Chronicis.Shared.Models;
 6using Microsoft.EntityFrameworkCore;
 7using System.Text.Json;
 8
 9namespace Chronicis.Api.Services;
 10
 11/// <summary>
 12/// Service for anonymous public access to worlds.
 13/// All methods return only publicly visible content - no authentication required.
 14/// </summary>
 15public sealed class PublicWorldService : IPublicWorldService
 16{
 117    private static readonly JsonSerializerOptions GeometryJsonOptions = new(JsonSerializerDefaults.Web);
 18
 19    private readonly ChronicisDbContext _context;
 20    private readonly ILogger<PublicWorldService> _logger;
 21    private readonly IArticleHierarchyService _hierarchyService;
 22    private readonly IBlobStorageService _blobStorage;
 23    private readonly IReadAccessPolicyService _readAccessPolicy;
 24    private readonly IMapBlobStore _mapBlobStore;
 25
 26    public PublicWorldService(
 27        ChronicisDbContext context,
 28        ILogger<PublicWorldService> logger,
 29        IArticleHierarchyService hierarchyService,
 30        IBlobStorageService blobStorage,
 31        IReadAccessPolicyService readAccessPolicy,
 32        IMapBlobStore mapBlobStore)
 33    {
 1834        _context = context;
 1835        _logger = logger;
 1836        _hierarchyService = hierarchyService;
 1837        _blobStorage = blobStorage;
 1838        _readAccessPolicy = readAccessPolicy;
 1839        _mapBlobStore = mapBlobStore;
 1840    }
 41
 42    /// <summary>
 43    /// Get a public world by its public slug.
 44    /// Returns null if world doesn't exist or is not public.
 45    /// </summary>
 46    public async Task<WorldDetailDto?> GetPublicWorldAsync(string publicSlug)
 47    {
 48        var normalizedSlug = _readAccessPolicy.NormalizePublicSlug(publicSlug);
 49
 50        var world = await _readAccessPolicy
 51            .ApplyPublicWorldSlugFilter(_context.Worlds.AsNoTracking(), normalizedSlug)
 52            .Include(w => w.Owner)
 53            .Include(w => w.Campaigns)
 54            .FirstOrDefaultAsync();
 55
 56        if (world == null)
 57        {
 58            _logger.LogTraceSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug);
 59            return null;
 60        }
 61
 62        _logger.LogTraceSanitized("Public world '{WorldName}' accessed via slug '{PublicSlug}'",
 63            world.Name, normalizedSlug);
 64
 65        return new WorldDetailDto
 66        {
 67            Id = world.Id,
 68            Name = world.Name,
 69            Slug = world.Slug,
 70            Description = world.Description,
 71            OwnerId = world.OwnerId,
 72            OwnerName = world.Owner?.DisplayName ?? "Unknown",
 73            CreatedAt = world.CreatedAt,
 74            CampaignCount = world.Campaigns?.Count ?? 0,
 75            IsPublic = world.IsPublic,
 76            IsTutorial = world.IsTutorial,
 77            PublicSlug = world.PublicSlug,
 78            // Include public campaigns
 79            Campaigns = world.Campaigns?.Select(c => new CampaignDto
 80            {
 81                Id = c.Id,
 82                Name = c.Name,
 83                Description = c.Description,
 84                WorldId = c.WorldId,
 85                IsActive = c.IsActive,
 86                StartedAt = c.StartedAt
 87            }).ToList() ?? new List<CampaignDto>()
 88        };
 89    }
 90
 91    /// <summary>
 92    /// Get the article tree for a public world.
 93    /// Only returns articles with Public visibility.
 94    /// Returns a hierarchical tree structure organized by virtual groups (Campaigns, Characters, Wiki).
 95    /// </summary>
 96    public async Task<List<ArticleTreeDto>> GetPublicArticleTreeAsync(string publicSlug)
 97    {
 98        var normalizedSlug = _readAccessPolicy.NormalizePublicSlug(publicSlug);
 99
 100        // First, verify the world exists and is public
 101        var world = await _readAccessPolicy
 102            .ApplyPublicWorldSlugFilter(_context.Worlds.AsNoTracking(), normalizedSlug)
 103            .Include(w => w.Campaigns)
 104                .ThenInclude(c => c.Arcs)
 105                    .ThenInclude(a => a.SessionEntities)
 106            .FirstOrDefaultAsync();
 107
 108        if (world == null)
 109        {
 110            _logger.LogTraceSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug);
 111            return new List<ArticleTreeDto>();
 112        }
 113
 114        // Get all public articles for this world
 115        var allPublicArticles = await _readAccessPolicy
 116            .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), world.Id)
 117            .Select(a => new ArticleTreeDto
 118            {
 119                Id = a.Id,
 120                Title = a.Title,
 121                Slug = a.Slug,
 122                ParentId = a.ParentId,
 123                WorldId = a.WorldId,
 124                CampaignId = a.CampaignId,
 125                ArcId = a.ArcId,
 126                SessionId = a.SessionId,
 127                Type = a.Type,
 128                Visibility = a.Visibility,
 129                HasChildren = false, // Will calculate below
 130                ChildCount = 0,      // Will calculate below
 131                Children = new List<ArticleTreeDto>(),
 132                CreatedAt = a.CreatedAt,
 133                EffectiveDate = a.EffectiveDate,
 134                IconEmoji = a.IconEmoji,
 135                CreatedBy = a.CreatedBy
 136            })
 137            .ToListAsync();
 138
 139        // Build article index and children relationships
 140        var articleIndex = allPublicArticles.ToDictionary(a => a.Id);
 141        var sessionNotesBySessionId = allPublicArticles
 142            .Where(a => a.Type == ArticleType.SessionNote && a.SessionId.HasValue)
 143            .GroupBy(a => a.SessionId!.Value)
 144            .ToDictionary(g => g.Key, g => g.ToList());
 145
 146        // Link children to parents
 147        foreach (var article in allPublicArticles)
 148        {
 149            if (article.ParentId.HasValue && articleIndex.TryGetValue(article.ParentId.Value, out var parent))
 150            {
 151                parent.Children ??= new List<ArticleTreeDto>();
 152                parent.Children.Add(article);
 153                parent.HasChildren = true;
 154                parent.ChildCount++;
 155            }
 156        }
 157
 158        // Sort children by title
 159        foreach (var article in allPublicArticles.Where(a => a.Children?.Any() == true))
 160        {
 161            article.Children = article.Children!.OrderBy(c => c.Title).ToList();
 162        }
 163
 164        // Build virtual groups
 165        var result = new List<ArticleTreeDto>();
 166
 167        // 1. Campaigns group - contains campaigns with their arcs and sessions
 168        var campaignsGroup = CreateVirtualGroup("campaigns", "Campaigns", "fa-solid fa-dungeon");
 169        foreach (var campaign in world.Campaigns?.OrderBy(c => c.Name) ?? Enumerable.Empty<Chronicis.Shared.Models.Campa
 170        {
 171            var campaignNode = new ArticleTreeDto
 172            {
 173                Id = campaign.Id,
 174                Title = campaign.Name,
 175                Slug = campaign.Name.ToLowerInvariant().Replace(" ", "-"),
 176                Type = ArticleType.WikiArticle, // Use WikiArticle as placeholder
 177                IconEmoji = "fa-solid fa-dungeon",
 178                Children = new List<ArticleTreeDto>(),
 179                IsVirtualGroup = true
 180            };
 181
 182            // Add arcs under campaign
 183            foreach (var arc in campaign.Arcs?.OrderBy(a => a.SortOrder).ThenBy(a => a.Name) ?? Enumerable.Empty<Chronic
 184            {
 185                var arcNode = new ArticleTreeDto
 186                {
 187                    Id = arc.Id,
 188                    Title = arc.Name,
 189                    Slug = arc.Name.ToLowerInvariant().Replace(" ", "-"),
 190                    Type = ArticleType.WikiArticle,
 191                    IconEmoji = "fa-solid fa-book-open",
 192                    Children = new List<ArticleTreeDto>(),
 193                    IsVirtualGroup = true
 194                };
 195
 196                var sessions = arc.SessionEntities?
 197                    .OrderBy(s => s.SessionDate ?? DateTime.MaxValue)
 198                    .ThenBy(s => s.Name)
 199                    .ThenBy(s => s.CreatedAt)
 200                    ?? Enumerable.Empty<Chronicis.Shared.Models.Session>();
 201
 202                foreach (var session in sessions)
 203                {
 204                    var rootSessionNotes = GetRootSessionNotesForSession(sessionNotesBySessionId, session.Id);
 205
 206                    if (!rootSessionNotes.Any())
 207                    {
 208                        continue;
 209                    }
 210
 211                    var sessionNode = new ArticleTreeDto
 212                    {
 213                        Id = session.Id,
 214                        Title = session.Name,
 215                        Slug = session.Name.ToLowerInvariant().Replace(" ", "-"),
 216                        Type = ArticleType.Session,
 217                        IconEmoji = "fa-solid fa-calendar-day",
 218                        Children = new List<ArticleTreeDto>(),
 219                        IsVirtualGroup = true,
 220                        HasAISummary = !string.IsNullOrWhiteSpace(session.AiSummary)
 221                    };
 222
 223                    foreach (var note in rootSessionNotes)
 224                    {
 225                        sessionNode.Children.Add(note);
 226                        sessionNode.HasChildren = true;
 227                        sessionNode.ChildCount++;
 228                    }
 229
 230                    arcNode.Children.Add(sessionNode);
 231                    arcNode.HasChildren = true;
 232                    arcNode.ChildCount++;
 233                }
 234
 235                if (arcNode.Children.Any())
 236                {
 237                    campaignNode.Children.Add(arcNode);
 238                    campaignNode.HasChildren = true;
 239                    campaignNode.ChildCount++;
 240                }
 241            }
 242
 243            if (campaignNode.Children.Any())
 244            {
 245                campaignsGroup.Children!.Add(campaignNode);
 246                campaignsGroup.HasChildren = true;
 247                campaignsGroup.ChildCount++;
 248            }
 249        }
 250
 251        if (campaignsGroup.Children!.Any())
 252        {
 253            result.Add(campaignsGroup);
 254        }
 255
 256        // 2. Player Characters group
 257        var charactersGroup = CreateVirtualGroup("characters", "Player Characters", "fa-solid fa-user-ninja");
 258        var characterArticles = allPublicArticles
 259            .Where(a => a.Type == ArticleType.Character && a.ParentId == null)
 260            .OrderBy(a => a.Title)
 261            .ToList();
 262
 263        foreach (var article in characterArticles)
 264        {
 265            charactersGroup.Children!.Add(article);
 266            charactersGroup.HasChildren = true;
 267            charactersGroup.ChildCount++;
 268        }
 269
 270        if (charactersGroup.Children!.Any())
 271        {
 272            result.Add(charactersGroup);
 273        }
 274
 275        // 3. Wiki group
 276        var wikiGroup = CreateVirtualGroup("wiki", "Wiki", "fa-solid fa-book");
 277        var wikiArticles = allPublicArticles
 278            .Where(a => a.Type == ArticleType.WikiArticle && a.ParentId == null)
 279            .OrderBy(a => a.Title)
 280            .ToList();
 281
 282        foreach (var article in wikiArticles)
 283        {
 284            wikiGroup.Children!.Add(article);
 285            wikiGroup.HasChildren = true;
 286            wikiGroup.ChildCount++;
 287        }
 288
 289        if (wikiGroup.Children!.Any())
 290        {
 291            result.Add(wikiGroup);
 292        }
 293
 294        // 4. Uncategorized (Legacy and other types without parents not already included)
 295        var includedIds = new HashSet<Guid>();
 296
 297        // Collect all IDs from campaigns/arcs/sessions
 298        foreach (var campaign in result.FirstOrDefault(r => r.Slug == "campaigns")?.Children ?? new List<ArticleTreeDto>
 299        {
 300            foreach (var arc in campaign.Children ?? new List<ArticleTreeDto>())
 301            {
 302                foreach (var session in arc.Children ?? new List<ArticleTreeDto>())
 303                {
 304                    CollectArticleIds(session, includedIds);
 305                }
 306            }
 307        }
 308
 309        // Collect character and wiki IDs
 310        foreach (var article in characterArticles)
 311        {
 312            CollectArticleIds(article, includedIds);
 313        }
 314        foreach (var article in wikiArticles)
 315        {
 316            CollectArticleIds(article, includedIds);
 317        }
 318
 319        var uncategorizedArticles = allPublicArticles
 320            .Where(a => a.ParentId == null &&
 321                        !includedIds.Contains(a.Id))
 322            .OrderBy(a => a.Title)
 323            .ToList();
 324
 325        if (uncategorizedArticles.Any())
 326        {
 327            var uncategorizedGroup = CreateVirtualGroup("uncategorized", "Uncategorized", "fa-solid fa-folder");
 328            foreach (var article in uncategorizedArticles)
 329            {
 330                uncategorizedGroup.Children!.Add(article);
 331                uncategorizedGroup.HasChildren = true;
 332                uncategorizedGroup.ChildCount++;
 333            }
 334            result.Add(uncategorizedGroup);
 335        }
 336
 337        _logger.LogTraceSanitized("Retrieved {Count} public articles for world '{PublicSlug}' in {GroupCount} groups",
 338            allPublicArticles.Count, normalizedSlug, result.Count);
 339
 340        return result;
 341    }
 342
 343    private static List<ArticleTreeDto> GetRootSessionNotesForSession(
 344        IReadOnlyDictionary<Guid, List<ArticleTreeDto>> sessionNotesBySessionId,
 345        Guid sessionId)
 346    {
 4347        if (!sessionNotesBySessionId.TryGetValue(sessionId, out var notes))
 348        {
 1349            return new List<ArticleTreeDto>();
 350        }
 351
 3352        var sessionNoteIds = notes.Select(n => n.Id).ToHashSet();
 3353        return notes
 3354            .Where(n => !n.ParentId.HasValue || !sessionNoteIds.Contains(n.ParentId.Value))
 3355            .OrderBy(n => n.Title)
 3356            .ToList();
 357    }
 358
 359    private static ArticleTreeDto CreateVirtualGroup(string slug, string title, string icon)
 360    {
 7361        return new ArticleTreeDto
 7362        {
 7363            Id = Guid.NewGuid(), // Virtual ID
 7364            Title = title,
 7365            Slug = slug,
 7366            Type = ArticleType.WikiArticle,
 7367            IconEmoji = icon,
 7368            HasChildren = false,
 7369            ChildCount = 0,
 7370            Children = new List<ArticleTreeDto>(),
 7371            IsVirtualGroup = true
 7372        };
 373    }
 374
 375    private static void CollectArticleIds(ArticleTreeDto article, HashSet<Guid> ids)
 376    {
 6377        ids.Add(article.Id);
 6378        if (article.Children != null)
 379        {
 16380            foreach (var child in article.Children)
 381            {
 3382                CollectArticleIds(child, ids);
 383            }
 384        }
 6385    }
 386
 387    /// <summary>
 388    /// Get a specific article by path in a public world.
 389    /// Returns null if article doesn't exist, world is not public, or article is not Public visibility.
 390    /// Path format: "article-slug/child-slug" (does not include world slug)
 391    /// </summary>
 392    public async Task<ArticleDto?> GetPublicArticleAsync(string publicSlug, string articlePath)
 393    {
 394        var normalizedSlug = _readAccessPolicy.NormalizePublicSlug(publicSlug);
 395
 396        // First, verify the world exists and is public
 397        var world = await _readAccessPolicy
 398            .ApplyPublicWorldSlugFilter(_context.Worlds.AsNoTracking(), normalizedSlug)
 399            .Select(w => new { w.Id, w.Name, w.Slug })
 400            .FirstOrDefaultAsync();
 401
 402        if (world == null)
 403        {
 404            _logger.LogTraceSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug);
 405            return null;
 406        }
 407
 408        if (string.IsNullOrWhiteSpace(articlePath))
 409        {
 410            _logger.LogTraceSanitized("Empty article path for public world '{PublicSlug}'", normalizedSlug);
 411            return null;
 412        }
 413
 414        var slugs = articlePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
 415        if (slugs.Length == 0)
 416            return null;
 417
 418        var resolvedArticle = await ArticleSlugPathResolver.ResolveAsync(
 419            slugs,
 420            async (slug, parentId, isRootLevel) =>
 421            {
 422                var query = _readAccessPolicy
 423                    .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), world.Id)
 424                    .Where(a => a.Slug == slug);
 425
 426                query = isRootLevel
 427                    ? query.Where(a => a.ParentId == null)
 428                    : query.Where(a => a.ParentId == parentId);
 429
 430                var article = await query
 431                    .Select(a => new { a.Id, a.Type })
 432                    .FirstOrDefaultAsync();
 433
 434                return article == null
 435                    ? null
 436                    : (article.Id, article.Type);
 437            },
 438            (_, _, _) => Task.FromResult<(Guid Id, ArticleType Type)?>(null));
 439
 440        if (resolvedArticle == null)
 441        {
 442            _logger.LogTraceSanitized("Public article not found for path '{Path}' in world '{PublicSlug}'",
 443                articlePath, normalizedSlug);
 444            return null;
 445        }
 446
 447        // Found the article, now get full details
 448        var article = await _readAccessPolicy
 449            .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), world.Id)
 450            .Where(a => a.Id == resolvedArticle.Value.Id)
 451            .Select(ArticleReadModelProjection.ArticleDetail)
 452            .FirstOrDefaultAsync();
 453
 454        if (article == null)
 455            return null;
 456
 457        // Build breadcrumbs (only including public articles) using centralised hierarchy service
 458        article.Breadcrumbs = await _hierarchyService.BuildBreadcrumbsAsync(resolvedArticle.Value.Id, new HierarchyWalkO
 459        {
 460            PublicOnly = true,
 461            IncludeWorldBreadcrumb = true,
 462            IncludeVirtualGroups = true,
 463            World = new WorldContext { Id = world.Id, Name = world.Name, Slug = world.Slug }
 464        });
 465
 466        _logger.LogTraceSanitized("Public article '{Title}' accessed in world '{PublicSlug}'",
 467            article.Title, normalizedSlug);
 468
 469        return article;
 470    }
 471
 472    /// <summary>
 473    /// Resolve an article ID to its public URL path.
 474    /// Returns null if the article doesn't exist, is not public, or doesn't belong to the specified world.
 475    /// </summary>
 476    public async Task<string?> GetPublicArticlePathAsync(string publicSlug, Guid articleId)
 477    {
 478        var normalizedSlug = _readAccessPolicy.NormalizePublicSlug(publicSlug);
 479
 480        // Verify the world exists and is public
 481        var world = await _readAccessPolicy
 482            .ApplyPublicWorldSlugFilter(_context.Worlds.AsNoTracking(), normalizedSlug)
 483            .Select(w => new { w.Id })
 484            .FirstOrDefaultAsync();
 485
 486        if (world == null)
 487        {
 488            _logger.LogTraceSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug);
 489            return null;
 490        }
 491
 492        // Get the article and verify it's public and belongs to this world
 493        var article = await _readAccessPolicy
 494            .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), world.Id)
 495            .Where(a => a.Id == articleId)
 496            .Select(a => new { a.Id, a.Slug, a.ParentId })
 497            .FirstOrDefaultAsync();
 498
 499        if (article == null)
 500        {
 501            _logger.LogTraceSanitized("Public article {ArticleId} not found in world '{PublicSlug}'", articleId, normali
 502            return null;
 503        }
 504
 505        // Build the path by walking up the parent tree.
 506        var slugs = new List<string> { article.Slug };
 507
 508        var currentParentId = article.ParentId;
 509
 510        while (currentParentId.HasValue)
 511        {
 512            var parentArticle = await _readAccessPolicy
 513                .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), world.Id)
 514                .Where(a => a.Id == currentParentId.Value)
 515                .Select(a => new { a.Slug, a.ParentId })
 516                .FirstOrDefaultAsync();
 517
 518            if (parentArticle == null)
 519            {
 520                // Parent is not public - this article's path is broken
 521                _logger.LogTraceSanitized("Parent article not public in chain for article {ArticleId}", articleId);
 522                return null;
 523            }
 524
 525            slugs.Insert(0, parentArticle.Slug);
 526            currentParentId = parentArticle.ParentId;
 527        }
 528
 529        var path = string.Join("/", slugs);
 530        _logger.LogTraceSanitized("Resolved article {ArticleId} to path '{Path}' in world '{PublicSlug}'",
 531            articleId, path, normalizedSlug);
 532
 533        return path;
 534    }
 535
 536    /// <summary>
 537    /// Resolve a public inline-image document ID to a fresh download URL.
 538    /// The document must be an image attached to a public article in a public world.
 539    /// </summary>
 540    public async Task<string?> GetPublicDocumentDownloadUrlAsync(Guid documentId)
 541    {
 542        var publicArticles = _readAccessPolicy
 543            .ApplyPublicVisibilityFilter(_context.Articles.AsNoTracking());
 544        var publicWorlds = _readAccessPolicy
 545            .ApplyPublicWorldFilter(_context.Worlds.AsNoTracking());
 546
 547        var document = await _context.WorldDocuments
 548            .AsNoTracking()
 549            .Where(d => d.Id == documentId
 550                        && d.ArticleId.HasValue
 551                        && d.ContentType.StartsWith("image/"))
 552            .Join(
 553                publicArticles,
 554                d => d.ArticleId,
 555                a => a.Id,
 556                (d, a) => new
 557                {
 558                    d.BlobPath,
 559                    d.WorldId,
 560                    ArticleWorldId = a.WorldId
 561                })
 562            .Join(
 563                publicWorlds,
 564                joined => joined.WorldId,
 565                w => w.Id,
 566                (joined, _) => joined)
 567            .FirstOrDefaultAsync();
 568
 569        if (document == null
 570            || document.ArticleWorldId != document.WorldId)
 571        {
 572            return null;
 573        }
 574
 575        return await _blobStorage.GenerateDownloadSasUrlAsync(document.BlobPath);
 576    }
 577
 578    /// <inheritdoc />
 579    public async Task<(GetBasemapReadUrlResponseDto? Basemap, string? Error)> GetPublicMapBasemapReadUrlAsync(string pub
 580    {
 581        var map = await GetPublicMapAsync(publicSlug, mapId);
 582        if (map == null)
 583        {
 584            return (null, "Map not found or not public");
 585        }
 586
 587        if (string.IsNullOrWhiteSpace(map.BasemapBlobKey))
 588        {
 589            return (null, "Basemap is missing for this map.");
 590        }
 591
 592        var readUrl = await _mapBlobStore.GenerateReadSasUrlAsync(map.BasemapBlobKey);
 593        return (new GetBasemapReadUrlResponseDto { ReadUrl = readUrl }, null);
 594    }
 595
 596    /// <inheritdoc />
 597    public async Task<List<MapLayerDto>?> GetPublicMapLayersAsync(string publicSlug, Guid mapId)
 598    {
 599        var map = await GetPublicMapAsync(publicSlug, mapId);
 600        if (map == null)
 601        {
 602            return null;
 603        }
 604
 605        return await _context.MapLayers
 606            .AsNoTracking()
 607            .Where(layer => layer.WorldMapId == mapId)
 608            .OrderBy(layer => layer.SortOrder)
 609            .Select(layer => new MapLayerDto
 610            {
 611                MapLayerId = layer.MapLayerId,
 612                ParentLayerId = layer.ParentLayerId,
 613                Name = layer.Name,
 614                SortOrder = layer.SortOrder,
 615                IsEnabled = layer.IsEnabled,
 616            })
 617            .ToListAsync();
 618    }
 619
 620    /// <inheritdoc />
 621    public async Task<List<MapPinResponseDto>?> GetPublicMapPinsAsync(string publicSlug, Guid mapId)
 622    {
 623        var map = await GetPublicMapAsync(publicSlug, mapId);
 624        if (map == null)
 625        {
 626            return null;
 627        }
 628
 629        var pins = await _context.MapFeatures
 630            .AsNoTracking()
 631            .Where(feature => feature.WorldMapId == mapId && feature.FeatureType == MapFeatureType.Point)
 632            .OrderBy(feature => feature.MapFeatureId)
 633            .Select(feature => new
 634            {
 635                feature.MapFeatureId,
 636                feature.WorldMapId,
 637                feature.MapLayerId,
 638                feature.Name,
 639                X = feature.X,
 640                Y = feature.Y,
 641                feature.LinkedArticleId,
 642            })
 643            .ToListAsync();
 644
 645        var linkedArticles = await GetPublicLinkedArticleTitlesAsync(
 646            map.WorldId,
 647            pins.Where(pin => pin.LinkedArticleId.HasValue).Select(pin => pin.LinkedArticleId!.Value));
 648
 649        return pins
 650            .Select(pin => new MapPinResponseDto
 651            {
 652                PinId = pin.MapFeatureId,
 653                MapId = pin.WorldMapId,
 654                LayerId = pin.MapLayerId,
 655                Name = pin.Name,
 656                X = pin.X,
 657                Y = pin.Y,
 658                LinkedArticle = pin.LinkedArticleId.HasValue
 659                    && linkedArticles.TryGetValue(pin.LinkedArticleId.Value, out var title)
 660                    ? new LinkedArticleSummaryDto
 661                    {
 662                        ArticleId = pin.LinkedArticleId.Value,
 663                        Title = title,
 664                    }
 665                    : null,
 666            })
 667            .ToList();
 668    }
 669
 670    /// <inheritdoc />
 671    public async Task<List<MapFeatureDto>?> GetPublicMapFeaturesAsync(string publicSlug, Guid mapId)
 672    {
 673        var map = await GetPublicMapAsync(publicSlug, mapId);
 674        if (map == null)
 675        {
 676            return null;
 677        }
 678
 679        var features = await _context.MapFeatures
 680            .AsNoTracking()
 681            .Where(feature => feature.WorldMapId == mapId)
 682            .OrderBy(feature => feature.MapFeatureId)
 683            .ToListAsync();
 684
 685        return await ToPublicMapFeatureDtosAsync(map.WorldId, features);
 686    }
 687
 688    private async Task<PublicMapLookup?> GetPublicMapAsync(string publicSlug, Guid mapId)
 689    {
 690        var normalizedSlug = _readAccessPolicy.NormalizePublicSlug(publicSlug);
 691        var worldId = await _readAccessPolicy
 692            .ApplyPublicWorldSlugFilter(_context.Worlds.AsNoTracking(), normalizedSlug)
 693            .Select(world => (Guid?)world.Id)
 694            .FirstOrDefaultAsync();
 695
 696        if (!worldId.HasValue)
 697        {
 698            return null;
 699        }
 700
 701        return await _context.WorldMaps
 702            .AsNoTracking()
 703            .Where(map => map.WorldId == worldId.Value && map.WorldMapId == mapId)
 704            .Select(map => new PublicMapLookup
 705            {
 706                WorldId = map.WorldId,
 707                BasemapBlobKey = map.BasemapBlobKey,
 708            })
 709            .FirstOrDefaultAsync();
 710    }
 711
 712    private async Task<Dictionary<Guid, string>> GetPublicLinkedArticleTitlesAsync(Guid worldId, IEnumerable<Guid> linke
 713    {
 714        var ids = linkedArticleIds
 715            .Distinct()
 716            .ToList();
 717
 718        if (ids.Count == 0)
 719        {
 720            return [];
 721        }
 722
 723        return await _readAccessPolicy
 724            .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), worldId)
 725            .Where(article => ids.Contains(article.Id))
 726            .ToDictionaryAsync(article => article.Id, article => article.Title);
 727    }
 728
 729    private Task<Dictionary<Guid, string>> GetPublicLinkedArticleTitlesAsync(Guid worldId, IEnumerable<MapFeature> featu
 730    {
 2731        return GetPublicLinkedArticleTitlesAsync(
 2732            worldId,
 2733            features
 2734                .Where(feature => feature.LinkedArticleId.HasValue)
 2735                .Select(feature => feature.LinkedArticleId!.Value));
 736    }
 737
 738    private async Task<List<MapFeatureDto>> ToPublicMapFeatureDtosAsync(Guid worldId, IReadOnlyCollection<MapFeature> fe
 739    {
 740        var linkedArticles = await GetPublicLinkedArticleTitlesAsync(worldId, features);
 741        var results = new List<MapFeatureDto>(features.Count);
 742
 743        foreach (var feature in features)
 744        {
 745            PolygonGeometryDto? polygon = null;
 746            if (feature.FeatureType == MapFeatureType.Polygon && !string.IsNullOrWhiteSpace(feature.GeometryBlobKey))
 747            {
 748                var geometryJson = await _mapBlobStore.LoadFeatureGeometryAsync(feature.GeometryBlobKey);
 749                polygon = geometryJson == null
 750                    ? null
 751                    : JsonSerializer.Deserialize<PolygonGeometryDto>(geometryJson, GeometryJsonOptions);
 752            }
 753
 754            results.Add(new MapFeatureDto
 755            {
 756                FeatureId = feature.MapFeatureId,
 757                MapId = feature.WorldMapId,
 758                LayerId = feature.MapLayerId,
 759                FeatureType = feature.FeatureType,
 760                Name = feature.Name,
 761                Color = feature.Color,
 762                LinkedArticleId = feature.LinkedArticleId,
 763                LinkedArticle = feature.LinkedArticleId.HasValue
 764                    && linkedArticles.TryGetValue(feature.LinkedArticleId.Value, out var title)
 765                    ? new LinkedArticleSummaryDto
 766                    {
 767                        ArticleId = feature.LinkedArticleId.Value,
 768                        Title = title,
 769                    }
 770                    : null,
 771                Point = feature.FeatureType == MapFeatureType.Point
 772                    ? new MapFeaturePointDto
 773                    {
 774                        X = feature.X,
 775                        Y = feature.Y,
 776                    }
 777                    : null,
 778                Polygon = polygon,
 779                Geometry = feature.FeatureType == MapFeatureType.Polygon && !string.IsNullOrWhiteSpace(feature.GeometryB
 780                    ? new MapFeatureGeometryReferenceDto
 781                    {
 782                        BlobKey = feature.GeometryBlobKey,
 783                        ETag = feature.GeometryETag,
 784                    }
 785                    : null,
 786            });
 787        }
 788
 789        return results;
 790    }
 791
 792    private sealed class PublicMapLookup
 793    {
 794        public Guid WorldId { get; init; }
 795
 796        public string? BasemapBlobKey { get; init; }
 797    }
 798}