< Summary

Information
Class: Chronicis.Api.Services.WorldMapService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/WorldMapService.cs
Line coverage
100%
Covered lines: 148
Uncovered lines: 0
Coverable lines: 148
Total lines: 1889
Line coverage: 100%
Branch coverage
100%
Covered branches: 52
Total branches: 52
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using System.Collections.Frozen;
 2using System.Text.Json;
 3using System.Text.RegularExpressions;
 4using Chronicis.Api.Data;
 5using Chronicis.Api.Models;
 6using Chronicis.Shared.DTOs.Maps;
 7using Chronicis.Shared.Enums;
 8using Chronicis.Shared.Models;
 9using Chronicis.Shared.Utilities;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace Chronicis.Api.Services;
 13
 14/// <summary>
 15/// Manages WorldMaps: creation with default layers, metadata retrieval, and basemap uploads.
 16/// </summary>
 17public sealed class WorldMapService : IWorldMapService
 18{
 19    private readonly ChronicisDbContext _db;
 20    private readonly IMapBlobStore _mapBlobStore;
 21    private readonly ILogger<WorldMapService> _logger;
 22    private const int MaxPinNameLength = 200;
 23    private const int MaxLayerNameLength = 200;
 124    private static readonly JsonSerializerOptions GeometryJsonOptions = new(JsonSerializerDefaults.Web);
 125    private static readonly FrozenSet<string> ProtectedDefaultLayerNames = new HashSet<string>(StringComparer.OrdinalIgn
 126    {
 127        "World",
 128        "Campaign",
 129        "Arc",
 130    }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
 31
 132    internal static readonly FrozenSet<string> AllowedContentTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCas
 133    {
 134        "image/png",
 135        "image/jpeg",
 136        "image/webp",
 137    }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
 138    private static readonly Regex WhitespaceCollapseRegex = new(@"\s+", RegexOptions.Compiled);
 39
 40    private readonly IReservedSlugProvider _reservedSlugProvider;
 41
 42    public WorldMapService(
 43        ChronicisDbContext db,
 44        IMapBlobStore mapBlobStore,
 45        IReservedSlugProvider reservedSlugProvider,
 46        ILogger<WorldMapService> logger)
 47    {
 15848        _db = db;
 15849        _mapBlobStore = mapBlobStore;
 15850        _reservedSlugProvider = reservedSlugProvider;
 15851        _logger = logger;
 15852    }
 53
 54    /// <inheritdoc/>
 55    public async Task<MapDto> CreateMapAsync(Guid worldId, Guid userId, MapCreateDto dto)
 56    {
 57        _logger.LogTraceSanitized("User {UserId} creating map '{Name}' in world {WorldId}", userId, dto.Name, worldId);
 58
 59        var world = await _db.Worlds
 60            .AsNoTracking()
 61            .FirstOrDefaultAsync(w => w.Id == worldId && w.OwnerId == userId);
 62
 63        if (world == null)
 64        {
 65            throw new UnauthorizedAccessException("World not found or access denied");
 66        }
 67
 68        var mapSlugBase = !string.IsNullOrWhiteSpace(dto.Slug) && SlugGenerator.IsValidSlug(dto.Slug.Trim())
 69            ? dto.Slug.Trim()
 70            : SlugGenerator.GenerateSlug(dto.Name);
 71        var existingMapSlugs = await _db.WorldMaps.AsNoTracking()
 72            .Where(m => m.WorldId == worldId)
 73            .Select(m => m.Slug)
 74            .ToHashSetAsync();
 75        var mapSlug = SlugGenerator.GenerateUniqueSiblingSlug(mapSlugBase, existingMapSlugs, _reservedSlugProvider.All);
 76        var map = new WorldMap
 77        {
 78            WorldMapId = Guid.NewGuid(),
 79            WorldId = worldId,
 80            Name = dto.Name,
 81            Slug = mapSlug,
 82            CreatedUtc = DateTime.UtcNow,
 83            UpdatedUtc = DateTime.UtcNow,
 84        };
 85
 86        _db.WorldMaps.Add(map);
 87
 88        _db.MapLayers.AddRange(
 89            new MapLayer { MapLayerId = Guid.NewGuid(), WorldMapId = map.WorldMapId, Name = "World", SortOrder = 0, IsEn
 90            new MapLayer { MapLayerId = Guid.NewGuid(), WorldMapId = map.WorldMapId, Name = "Campaign", SortOrder = 1, I
 91            new MapLayer { MapLayerId = Guid.NewGuid(), WorldMapId = map.WorldMapId, Name = "Arc", SortOrder = 2, IsEnab
 92
 93        await _db.SaveChangesAsync();
 94
 95        _logger.LogTraceSanitized("Created map {MapId} with 3 default layers", map.WorldMapId);
 96
 97        return ToMapDto(map, world.Slug);
 98    }
 99
 100    /// <inheritdoc/>
 101    public async Task<MapDto?> GetMapAsync(Guid mapId, Guid userId)
 102    {
 103        _logger.LogTraceSanitized("User {UserId} getting map {MapId}", userId, mapId);
 104
 105        var map = await _db.WorldMaps
 106            .Include(m => m.World)
 107                .ThenInclude(w => w.Members)
 108            .Include(m => m.WorldMapCampaigns)
 109            .Include(m => m.WorldMapArcs)
 110            .FirstOrDefaultAsync(m => m.WorldMapId == mapId);
 111
 112        if (map == null)
 113        {
 114            return null;
 115        }
 116
 117        var hasAccess = map.World.OwnerId == userId
 118            || map.World.Members.Any(m => m.UserId == userId);
 119
 120        if (!hasAccess)
 121        {
 122            return null;
 123        }
 124
 125        return ToMapDto(map);
 126    }
 127
 128    /// <inheritdoc/>
 129    public async Task<List<MapLayerDto>> ListLayersForMapAsync(Guid worldId, Guid mapId, Guid userId)
 130    {
 131        _logger.LogTraceSanitized("User {UserId} listing layers for map {MapId}", userId, mapId);
 132
 133        await EnsureWorldMembershipAsync(worldId, userId);
 134        await EnsureMapInWorldAsync(worldId, mapId);
 135
 136        return await _db.MapLayers
 137            .AsNoTracking()
 138            .Where(l => l.WorldMapId == mapId)
 139            .OrderBy(l => l.SortOrder)
 140            .Select(l => new MapLayerDto
 141            {
 142                MapLayerId = l.MapLayerId,
 143                ParentLayerId = l.ParentLayerId,
 144                Name = l.Name,
 145                SortOrder = l.SortOrder,
 146                IsEnabled = l.IsEnabled,
 147            })
 148            .ToListAsync();
 149    }
 150
 151    Task<MapLayerDto> IWorldMapService.CreateLayer(Guid worldId, Guid mapId, Guid userId, string name, Guid? parentLayer
 1152        CreateLayerAsync(worldId, mapId, userId, name, parentLayerId);
 153
 154    public async Task<MapLayerDto> CreateLayerAsync(Guid worldId, Guid mapId, Guid userId, string name, Guid? parentLaye
 155    {
 156        _logger.LogTraceSanitized("User {UserId} creating layer on map {MapId}", userId, mapId);
 157
 158        await EnsureWorldMembershipAsync(worldId, userId);
 159
 160        var map = await _db.WorldMaps
 161            .AsNoTracking()
 162            .FirstOrDefaultAsync(existingMap => existingMap.WorldMapId == mapId && existingMap.WorldId == worldId);
 163
 164        if (map == null)
 165        {
 166            throw new ArgumentException("Map not found");
 167        }
 168
 169        var normalizedName = NormalizeLayerName(name);
 170        var normalizedNameLower = normalizedName.ToLowerInvariant();
 171        var hasDuplicateName = await _db.MapLayers
 172            .AsNoTracking()
 173            .AnyAsync(layer =>
 174                layer.WorldMapId == mapId
 175                && layer.Name.ToLower() == normalizedNameLower);
 176
 177        if (hasDuplicateName)
 178        {
 179            throw new ArgumentException("Layer name already exists");
 180        }
 181
 182        if (parentLayerId.HasValue)
 183        {
 184            var parentLayer = await _db.MapLayers
 185                .AsNoTracking()
 186                .FirstOrDefaultAsync(layer => layer.MapLayerId == parentLayerId.Value);
 187
 188            if (parentLayer == null)
 189            {
 190                throw new ArgumentException("Parent layer not found");
 191            }
 192
 193            if (parentLayer.WorldMapId != mapId)
 194            {
 195                throw new ArgumentException("Parent layer does not belong to map");
 196            }
 197        }
 198
 199        var nextSortOrder = await _db.MapLayers
 200            .AsNoTracking()
 201            .Where(layer => layer.WorldMapId == mapId && layer.ParentLayerId == parentLayerId)
 202            .Select(layer => (int?)layer.SortOrder)
 203            .MaxAsync() ?? -1;
 204
 205        var createdLayer = new MapLayer
 206        {
 207            MapLayerId = Guid.NewGuid(),
 208            WorldMapId = map.WorldMapId,
 209            ParentLayerId = parentLayerId,
 210            Name = normalizedName,
 211            SortOrder = nextSortOrder + 1,
 212            IsEnabled = true,
 213        };
 214
 215        _db.MapLayers.Add(createdLayer);
 216        await _db.SaveChangesAsync();
 217
 218        return new MapLayerDto
 219        {
 220            MapLayerId = createdLayer.MapLayerId,
 221            ParentLayerId = createdLayer.ParentLayerId,
 222            Name = createdLayer.Name,
 223            SortOrder = createdLayer.SortOrder,
 224            IsEnabled = createdLayer.IsEnabled,
 225        };
 226    }
 227
 228    /// <inheritdoc/>
 229    public async Task<MapDto> UpdateMapAsync(Guid worldId, Guid mapId, Guid userId, MapUpdateDto dto)
 230    {
 231        _logger.LogTraceSanitized("User {UserId} updating map {MapId} in world {WorldId}", userId, mapId, worldId);
 232
 233        var map = await _db.WorldMaps
 234            .Include(m => m.World)
 235            .FirstOrDefaultAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 236
 237        if (map == null)
 238        {
 239            throw new InvalidOperationException("Map not found");
 240        }
 241
 242        if (map.World.OwnerId != userId)
 243        {
 244            throw new UnauthorizedAccessException("Only the world owner can update maps");
 245        }
 246
 247        var trimmedName = dto.Name.Trim();
 248        if (map.Name != trimmedName)
 249        {
 250            map.Slug = await GenerateUniqueMapSlugAsync(trimmedName, map.WorldId, map.WorldMapId);
 251        }
 252
 253        map.Name = trimmedName;
 254        map.UpdatedUtc = DateTime.UtcNow;
 255
 256        await _db.SaveChangesAsync();
 257
 258        return ToMapDto(map);
 259    }
 260
 261    /// <inheritdoc/>
 262    public async Task<List<MapSummaryDto>> ListMapsForWorldAsync(Guid worldId, Guid userId)
 263    {
 264        _logger.LogTraceSanitized("User {UserId} listing maps for world {WorldId}", userId, worldId);
 265
 266        var hasAccess = await _db.Worlds
 267            .AsNoTracking()
 268            .AnyAsync(w => w.Id == worldId
 269                && (w.OwnerId == userId || w.Members.Any(m => m.UserId == userId)));
 270
 271        if (!hasAccess)
 272        {
 273            throw new UnauthorizedAccessException("World not found or access denied");
 274        }
 275
 276        var maps = await _db.WorldMaps
 277            .AsNoTracking()
 278            .Include(m => m.WorldMapCampaigns)
 279            .Include(m => m.WorldMapArcs)
 280            .Where(m => m.WorldId == worldId)
 281            .OrderBy(m => m.Name)
 282            .ToListAsync();
 283
 284        return maps.Select(ToMapSummaryDto).ToList();
 285    }
 286
 287    /// <inheritdoc/>
 288    public async Task<List<MapAutocompleteDto>> SearchMapsForWorldAsync(Guid worldId, Guid userId, string? query)
 289    {
 290        _logger.LogTraceSanitized("User {UserId} searching map autocomplete for world {WorldId}", userId, worldId);
 291
 292        await EnsureWorldMembershipAsync(worldId, userId);
 293
 294        var normalizedQuery = query?.Trim();
 295        var mapsQuery = _db.WorldMaps
 296            .AsNoTracking()
 297            .Where(m => m.WorldId == worldId);
 298
 299        if (!string.IsNullOrWhiteSpace(normalizedQuery))
 300        {
 301            var normalizedQueryLower = normalizedQuery.ToLowerInvariant();
 302            mapsQuery = mapsQuery.Where(m => m.Name.ToLower().Contains(normalizedQueryLower));
 303        }
 304
 305        return await mapsQuery
 306            .OrderBy(m => m.Name)
 307            .Select(m => new MapAutocompleteDto
 308            {
 309                MapId = m.WorldMapId,
 310                Name = m.Name,
 311            })
 312            .ToListAsync();
 313    }
 314
 315    /// <inheritdoc/>
 316    public async Task<List<MapFeatureAutocompleteDto>> SearchMapFeaturesForWorldAsync(Guid worldId, Guid userId, string?
 317    {
 318        _logger.LogTraceSanitized("User {UserId} searching feature autocomplete for world {WorldId}", userId, worldId);
 319
 320        await EnsureWorldMembershipAsync(worldId, userId);
 321
 322        return await BuildMapFeatureAutocompleteQuery(worldId, mapId: null, query).ToListAsync();
 323    }
 324
 325    /// <inheritdoc/>
 326    public async Task<List<MapFeatureAutocompleteDto>> SearchMapFeaturesForMapAsync(Guid worldId, Guid mapId, Guid userI
 327    {
 328        _logger.LogTraceSanitized(
 329            "User {UserId} searching feature autocomplete for map {MapId} in world {WorldId}",
 330            userId,
 331            mapId,
 332            worldId);
 333
 334        await EnsureWorldMembershipAsync(worldId, userId);
 335        await EnsureMapInWorldAsync(worldId, mapId);
 336
 337        return await BuildMapFeatureAutocompleteQuery(worldId, mapId, query).ToListAsync();
 338    }
 339
 340    private IQueryable<MapFeatureAutocompleteDto> BuildMapFeatureAutocompleteQuery(Guid worldId, Guid? mapId, string? qu
 341    {
 3342        var normalizedQuery = NormalizeAutocompleteQuery(query);
 3343        var featuresQuery = _db.MapFeatures
 3344            .AsNoTracking()
 3345            .Join(
 3346                _db.WorldMaps.AsNoTracking(),
 3347                feature => feature.WorldMapId,
 3348                map => map.WorldMapId,
 3349                (feature, map) => new { feature, map })
 3350            .Where(result => result.map.WorldId == worldId);
 351
 3352        if (mapId.HasValue)
 353        {
 1354            featuresQuery = featuresQuery.Where(result => result.feature.WorldMapId == mapId.Value);
 355        }
 356
 3357        var suggestionsQuery = featuresQuery
 3358            .GroupJoin(
 3359                _db.Articles.AsNoTracking(),
 3360                result => result.feature.LinkedArticleId,
 3361                article => article.Id,
 3362                (result, articles) => new
 3363                {
 3364                    result.feature.MapFeatureId,
 3365                    result.feature.WorldMapId,
 3366                    MapName = result.map.Name,
 3367                    FeatureName = result.feature.Name,
 3368                    LinkedArticleTitle = articles.Select(article => article.Title).FirstOrDefault(),
 3369                });
 370
 3371        if (!string.IsNullOrWhiteSpace(normalizedQuery))
 372        {
 2373            suggestionsQuery = suggestionsQuery.Where(result =>
 2374                (!string.IsNullOrWhiteSpace(result.FeatureName) && result.FeatureName.ToLower().Contains(normalizedQuery
 2375                || (!string.IsNullOrWhiteSpace(result.LinkedArticleTitle) && result.LinkedArticleTitle.ToLower().Contain
 376        }
 377
 3378        return suggestionsQuery
 3379            .OrderBy(result => result.FeatureName ?? result.LinkedArticleTitle ?? string.Empty)
 3380            .ThenBy(result => result.MapName)
 3381            .ThenBy(result => result.MapFeatureId)
 3382            .Select(result => new MapFeatureAutocompleteDto
 3383            {
 3384                MapFeatureId = result.MapFeatureId,
 3385                MapId = result.WorldMapId,
 3386                MapName = result.MapName,
 3387                FeatureName = result.FeatureName,
 3388                LinkedArticleTitle = result.LinkedArticleTitle,
 3389                DisplayText = !string.IsNullOrWhiteSpace(result.FeatureName)
 3390                    ? result.FeatureName!
 3391                    : result.LinkedArticleTitle ?? "Unnamed Feature",
 3392            })
 3393            .Take(25);
 394    }
 395
 396    /// <inheritdoc/>
 397    public async Task<MapPinResponseDto> CreatePinAsync(Guid worldId, Guid mapId, Guid userId, MapPinCreateDto dto)
 398    {
 399        _logger.LogTraceSanitized("User {UserId} creating pin on map {MapId}", userId, mapId);
 400
 401        await EnsureWorldMembershipAsync(worldId, userId);
 402        ValidateNormalizedCoordinates(dto.X, dto.Y);
 403        var pinName = NormalizePinName(dto.Name);
 404        var layerId = dto.LayerId.HasValue
 405            ? await ResolveRequestedLayerIdAsync(worldId, mapId, dto.LayerId.Value)
 406            : await ResolveDefaultLayerIdAsync(worldId, mapId);
 407
 408        var pin = new MapFeature
 409        {
 410            MapFeatureId = Guid.NewGuid(),
 411            WorldMapId = mapId,
 412            MapLayerId = layerId,
 413            FeatureType = MapFeatureType.Point,
 414            Name = pinName,
 415            X = dto.X,
 416            Y = dto.Y,
 417            LinkedArticleId = dto.LinkedArticleId,
 418        };
 419
 420        _db.MapFeatures.Add(pin);
 421        await _db.SaveChangesAsync();
 422
 423        return await GetMapPinResponseAsync(pin.MapFeatureId, mapId);
 424    }
 425
 426    /// <inheritdoc/>
 427    public async Task<List<MapPinResponseDto>> ListPinsForMapAsync(Guid worldId, Guid mapId, Guid userId)
 428    {
 429        _logger.LogTraceSanitized("User {UserId} listing pins for map {MapId}", userId, mapId);
 430
 431        await EnsureWorldMembershipAsync(worldId, userId);
 432        await EnsureMapInWorldAsync(worldId, mapId);
 433
 434        var pins = await _db.MapFeatures
 435            .AsNoTracking()
 436            .Where(mf => mf.WorldMapId == mapId && mf.FeatureType == MapFeatureType.Point)
 437            .OrderBy(mf => mf.MapFeatureId)
 438            .Select(mf => new
 439            {
 440                mf.MapFeatureId,
 441                mf.WorldMapId,
 442                mf.MapLayerId,
 443                mf.Name,
 444                X = mf.X,
 445                Y = mf.Y,
 446                mf.LinkedArticleId,
 447            })
 448            .ToListAsync();
 449
 450        var linkedArticleIds = pins
 451            .Where(p => p.LinkedArticleId.HasValue)
 452            .Select(p => p.LinkedArticleId!.Value)
 453            .Distinct()
 454            .ToList();
 455
 456        var linkedArticles = linkedArticleIds.Count == 0
 457            ? new Dictionary<Guid, string>()
 458            : await _db.Articles
 459                .AsNoTracking()
 460                .Where(a => linkedArticleIds.Contains(a.Id))
 461                .ToDictionaryAsync(a => a.Id, a => a.Title);
 462
 463        return pins
 464            .Select(pin => new MapPinResponseDto
 465            {
 466                PinId = pin.MapFeatureId,
 467                MapId = pin.WorldMapId,
 468                LayerId = pin.MapLayerId,
 469                Name = pin.Name,
 470                X = pin.X,
 471                Y = pin.Y,
 472                LinkedArticle = pin.LinkedArticleId.HasValue
 473                    && linkedArticles.TryGetValue(pin.LinkedArticleId.Value, out var title)
 474                    ? new LinkedArticleSummaryDto
 475                    {
 476                        ArticleId = pin.LinkedArticleId.Value,
 477                        Title = title,
 478                    }
 479                    : null,
 480            })
 481            .ToList();
 482    }
 483
 484    /// <inheritdoc/>
 485    public async Task<MapPinResponseDto> UpdatePinPositionAsync(
 486        Guid worldId, Guid mapId, Guid pinId, Guid userId, MapPinPositionUpdateDto dto)
 487    {
 488        _logger.LogTraceSanitized("User {UserId} updating pin {PinId} on map {MapId}", userId, pinId, mapId);
 489
 490        await EnsureWorldMembershipAsync(worldId, userId);
 491        await EnsureMapInWorldAsync(worldId, mapId);
 492        ValidateNormalizedCoordinates(dto.X, dto.Y);
 493
 494        var pin = await _db.MapFeatures
 495            .FirstOrDefaultAsync(mf =>
 496                mf.MapFeatureId == pinId
 497                && mf.WorldMapId == mapId
 498                && mf.FeatureType == MapFeatureType.Point);
 499
 500        if (pin == null)
 501        {
 502            throw new InvalidOperationException("Pin not found");
 503        }
 504
 505        pin.X = dto.X;
 506        pin.Y = dto.Y;
 507
 508        await _db.SaveChangesAsync();
 509
 510        return await GetMapPinResponseAsync(pin.MapFeatureId, mapId);
 511    }
 512
 513    Task IWorldMapService.UpdateLayerVisibility(
 514        Guid worldId, Guid mapId, Guid layerId, Guid userId, bool isEnabled) =>
 1515        UpdateLayerVisibilityAsync(worldId, mapId, layerId, userId, isEnabled);
 516
 517    public async Task UpdateLayerVisibilityAsync(
 518        Guid worldId, Guid mapId, Guid layerId, Guid userId, bool isEnabled)
 519    {
 520        _logger.LogTraceSanitized(
 521            "User {UserId} updating layer visibility for layer {LayerId} on map {MapId}",
 522            userId,
 523            layerId,
 524            mapId);
 525
 526        await EnsureWorldMembershipAsync(worldId, userId);
 527
 528        var map = await _db.WorldMaps
 529            .AsNoTracking()
 530            .FirstOrDefaultAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 531
 532        if (map == null)
 533        {
 534            throw new ArgumentException("Map not found");
 535        }
 536
 537        var layer = await _db.MapLayers
 538            .FirstOrDefaultAsync(l => l.MapLayerId == layerId);
 539
 540        if (layer == null)
 541        {
 542            throw new ArgumentException("Layer not found");
 543        }
 544
 545        if (layer.WorldMapId != map.WorldMapId)
 546        {
 547            throw new ArgumentException("Layer does not belong to map");
 548        }
 549
 550        layer.IsEnabled = isEnabled;
 551
 552        await _db.SaveChangesAsync();
 553    }
 554
 555    Task IWorldMapService.ReorderLayers(Guid worldId, Guid mapId, Guid userId, IList<Guid> layerIds) =>
 10556        ReorderLayersAsync(worldId, mapId, userId, layerIds);
 557
 558    private async Task ReorderLayersAsync(Guid worldId, Guid mapId, Guid userId, IList<Guid> layerIds)
 559    {
 560        _logger.LogTraceSanitized("User {UserId} reordering layers on map {MapId}", userId, mapId);
 561
 562        await EnsureWorldMembershipAsync(worldId, userId);
 563
 564        if (layerIds == null || layerIds.Count == 0)
 565        {
 566            throw new ArgumentException("LayerIds are required");
 567        }
 568
 569        var mapExists = await _db.WorldMaps
 570            .AsNoTracking()
 571            .AnyAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 572
 573        if (!mapExists)
 574        {
 575            throw new ArgumentException("Map not found");
 576        }
 577
 578        if (layerIds.Distinct().Count() != layerIds.Count)
 579        {
 580            throw new ArgumentException("Duplicate layer IDs are not allowed");
 581        }
 582
 583        var requestedLayers = await _db.MapLayers
 584            .Where(layer => layerIds.Contains(layer.MapLayerId))
 585            .ToListAsync();
 586
 587        if (requestedLayers.Count != layerIds.Count)
 588        {
 589            throw new ArgumentException("Layer not found");
 590        }
 591
 592        if (requestedLayers.Any(layer => layer.WorldMapId != mapId))
 593        {
 594            throw new ArgumentException("Layer does not belong to map");
 595        }
 596
 597        var parentLayerIds = requestedLayers
 598            .Select(layer => layer.ParentLayerId)
 599            .Distinct()
 600            .ToList();
 601
 602        if (parentLayerIds.Count != 1)
 603        {
 604            throw new ArgumentException("Layers must share the same parent");
 605        }
 606
 607        var parentLayerId = parentLayerIds[0];
 608        var siblingLayerIds = await _db.MapLayers
 609            .Where(layer => layer.WorldMapId == mapId && layer.ParentLayerId == parentLayerId)
 610            .Select(layer => layer.MapLayerId)
 611            .ToListAsync();
 612
 613        if (siblingLayerIds.Count != layerIds.Count || !siblingLayerIds.ToHashSet().SetEquals(layerIds))
 614        {
 615            throw new ArgumentException("LayerIds must include all sibling layers");
 616        }
 617
 618        var mapLayersById = requestedLayers.ToDictionary(layer => layer.MapLayerId);
 619        for (var index = 0; index < layerIds.Count; index++)
 620        {
 621            mapLayersById[layerIds[index]].SortOrder = index;
 622        }
 623
 624        await _db.SaveChangesAsync();
 625    }
 626
 627    Task IWorldMapService.RenameLayer(Guid worldId, Guid mapId, Guid userId, Guid layerId, string name) =>
 6628        RenameLayerAsync(worldId, mapId, userId, layerId, name);
 629
 630    public async Task RenameLayerAsync(Guid worldId, Guid mapId, Guid userId, Guid layerId, string name)
 631    {
 632        _logger.LogTraceSanitized("User {UserId} renaming layer {LayerId} on map {MapId}", userId, layerId, mapId);
 633
 634        await EnsureWorldMembershipAsync(worldId, userId);
 635
 636        var mapExists = await _db.WorldMaps
 637            .AsNoTracking()
 638            .AnyAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 639
 640        if (!mapExists)
 641        {
 642            throw new ArgumentException("Map not found");
 643        }
 644
 645        var layer = await _db.MapLayers
 646            .FirstOrDefaultAsync(l => l.MapLayerId == layerId);
 647
 648        if (layer == null)
 649        {
 650            throw new ArgumentException("Layer not found");
 651        }
 652
 653        if (layer.WorldMapId != mapId)
 654        {
 655            throw new ArgumentException("Layer does not belong to map");
 656        }
 657
 658        if (IsProtectedDefaultLayer(layer.Name))
 659        {
 660            throw new ArgumentException("Default layers cannot be renamed");
 661        }
 662
 663        layer.Name = NormalizeLayerName(name);
 664
 665        await _db.SaveChangesAsync();
 666    }
 667
 668    /// <inheritdoc/>
 669    public async Task SetLayerParentAsync(Guid worldId, Guid mapId, Guid userId, Guid layerId, Guid? parentLayerId)
 670    {
 671        _logger.LogTraceSanitized("User {UserId} setting parent for layer {LayerId} on map {MapId}", userId, layerId, ma
 672
 673        await EnsureWorldMembershipAsync(worldId, userId);
 674
 675        var mapExists = await _db.WorldMaps
 676            .AsNoTracking()
 677            .AnyAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 678
 679        if (!mapExists)
 680        {
 681            throw new ArgumentException("Map not found");
 682        }
 683
 684        var layer = await _db.MapLayers
 685            .FirstOrDefaultAsync(l => l.MapLayerId == layerId && l.WorldMapId == mapId);
 686
 687        if (layer == null)
 688        {
 689            throw new ArgumentException("Layer not found");
 690        }
 691
 692        if (parentLayerId == layerId)
 693        {
 694            throw new ArgumentException("Layer cannot be its own parent");
 695        }
 696
 697        if (layer.ParentLayerId == parentLayerId)
 698        {
 699            return;
 700        }
 701
 702        if (IsProtectedDefaultLayer(layer.Name))
 703        {
 704            throw new ArgumentException("Default layers cannot be re-parented");
 705        }
 706
 707        if (parentLayerId == null)
 708        {
 709            layer.ParentLayerId = null;
 710            await _db.SaveChangesAsync();
 711            return;
 712        }
 713
 714        var parent = await _db.MapLayers
 715            .AsNoTracking()
 716            .FirstOrDefaultAsync(l => l.MapLayerId == parentLayerId.Value && l.WorldMapId == mapId);
 717
 718        if (parent == null)
 719        {
 720            throw new ArgumentException("Parent layer not found in map");
 721        }
 722
 723        await EnsureValidParentChainAsync(mapId, layerId, parent.MapLayerId);
 724
 725        layer.ParentLayerId = parent.MapLayerId;
 726        await _db.SaveChangesAsync();
 727    }
 728
 729    Task IWorldMapService.DeleteLayer(Guid worldId, Guid mapId, Guid userId, Guid layerId) =>
 12730        DeleteLayerAsync(worldId, mapId, userId, layerId);
 731
 732    public async Task DeleteLayerAsync(Guid worldId, Guid mapId, Guid userId, Guid layerId)
 733    {
 734        _logger.LogTraceSanitized("User {UserId} deleting layer {LayerId} on map {MapId}", userId, layerId, mapId);
 735
 736        await EnsureWorldMembershipAsync(worldId, userId);
 737
 738        var mapExists = await _db.WorldMaps
 739            .AsNoTracking()
 740            .AnyAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 741
 742        if (!mapExists)
 743        {
 744            throw new ArgumentException("Map not found");
 745        }
 746
 747        var layer = await _db.MapLayers
 748            .FirstOrDefaultAsync(l => l.MapLayerId == layerId);
 749
 750        if (layer == null)
 751        {
 752            throw new ArgumentException("Layer not found");
 753        }
 754
 755        if (layer.WorldMapId != mapId)
 756        {
 757            throw new ArgumentException("Layer does not belong to map");
 758        }
 759
 760        if (IsProtectedDefaultLayer(layer.Name))
 761        {
 762            throw new ArgumentException("Default layers cannot be deleted");
 763        }
 764
 765        var hasChildren = await _db.MapLayers
 766            .AsNoTracking()
 767            .AnyAsync(existingLayer =>
 768                existingLayer.WorldMapId == layer.WorldMapId
 769                && existingLayer.ParentLayerId == layer.MapLayerId);
 770
 771        if (hasChildren)
 772        {
 773            throw new ArgumentException("Layer cannot be deleted while child layers exist");
 774        }
 775
 776        var hasFeatures = await _db.MapFeatures
 777            .AsNoTracking()
 778            .AnyAsync(feature => feature.MapLayerId == layerId);
 779
 780        if (hasFeatures)
 781        {
 782            throw new ArgumentException("Layer cannot be deleted while features reference it");
 783        }
 784
 785        var siblingParentLayerId = layer.ParentLayerId;
 786        var siblingWorldMapId = layer.WorldMapId;
 787
 788        _db.MapLayers.Remove(layer);
 789
 790        var remainingSiblingLayers = await _db.MapLayers
 791            .Where(existingLayer =>
 792                existingLayer.WorldMapId == siblingWorldMapId
 793                && existingLayer.ParentLayerId == siblingParentLayerId
 794                && existingLayer.MapLayerId != layerId)
 795            .OrderBy(existingLayer => existingLayer.SortOrder)
 796            .ThenBy(existingLayer => existingLayer.MapLayerId)
 797            .ToListAsync();
 798
 799        for (var index = 0; index < remainingSiblingLayers.Count; index++)
 800        {
 801            remainingSiblingLayers[index].SortOrder = index;
 802        }
 803
 804        await _db.SaveChangesAsync();
 805    }
 806
 807    /// <inheritdoc/>
 808    public async Task DeletePinAsync(Guid worldId, Guid mapId, Guid pinId, Guid userId)
 809    {
 810        _logger.LogTraceSanitized("User {UserId} deleting pin {PinId} on map {MapId}", userId, pinId, mapId);
 811
 812        await EnsureWorldMembershipAsync(worldId, userId);
 813        await EnsureMapInWorldAsync(worldId, mapId);
 814
 815        var pin = await _db.MapFeatures
 816            .FirstOrDefaultAsync(mf =>
 817                mf.MapFeatureId == pinId
 818                && mf.WorldMapId == mapId
 819                && mf.FeatureType == MapFeatureType.Point);
 820
 821        if (pin == null)
 822        {
 823            throw new InvalidOperationException("Pin not found");
 824        }
 825
 826        _db.MapFeatures.Remove(pin);
 827        await _db.SaveChangesAsync();
 828    }
 829
 830    /// <inheritdoc/>
 831    public async Task<MapFeatureDto> CreateFeatureAsync(Guid worldId, Guid mapId, Guid userId, MapFeatureCreateDto dto)
 832    {
 833        _logger.LogTraceSanitized("User {UserId} creating feature on map {MapId}", userId, mapId);
 834
 835        await EnsureWorldMembershipAsync(worldId, userId);
 836        var layerId = await ResolveRequestedLayerIdAsync(worldId, mapId, dto.LayerId);
 837        var featureName = NormalizePinName(dto.Name);
 838
 839        if (dto.FeatureType == MapFeatureType.Point)
 840        {
 841            if (dto.Point == null)
 842            {
 843                throw new ArgumentException("Point geometry is required");
 844            }
 845
 846            ValidateNormalizedCoordinates(dto.Point.X, dto.Point.Y);
 847
 848            var pointFeature = new MapFeature
 849            {
 850                MapFeatureId = Guid.NewGuid(),
 851                WorldMapId = mapId,
 852                MapLayerId = layerId,
 853                FeatureType = MapFeatureType.Point,
 854                Name = featureName,
 855                Color = dto.Color,
 856                X = dto.Point.X,
 857                Y = dto.Point.Y,
 858                LinkedArticleId = dto.LinkedArticleId,
 859            };
 860
 861            _db.MapFeatures.Add(pointFeature);
 862            await _db.SaveChangesAsync();
 863            return await GetMapFeatureResponseAsync(pointFeature.MapFeatureId, mapId);
 864        }
 865
 866        if (dto.FeatureType != MapFeatureType.Polygon)
 867        {
 868            throw new ArgumentException("Unsupported feature type");
 869        }
 870
 871        var normalizedPolygon = NormalizePolygonGeometry(dto.Polygon);
 872        var feature = new MapFeature
 873        {
 874            MapFeatureId = Guid.NewGuid(),
 875            WorldMapId = mapId,
 876            MapLayerId = layerId,
 877            FeatureType = MapFeatureType.Polygon,
 878            Name = featureName,
 879            Color = dto.Color,
 880            LinkedArticleId = dto.LinkedArticleId,
 881        };
 882
 883        feature.GeometryBlobKey = _mapBlobStore.BuildFeatureGeometryBlobKey(mapId, layerId, feature.MapFeatureId);
 884        var geometryJson = JsonSerializer.Serialize(normalizedPolygon, GeometryJsonOptions);
 885        feature.GeometryETag = await _mapBlobStore.SaveFeatureGeometryAsync(feature.GeometryBlobKey, geometryJson);
 886
 887        _db.MapFeatures.Add(feature);
 888        await _db.SaveChangesAsync();
 889
 890        return await GetMapFeatureResponseAsync(feature.MapFeatureId, mapId);
 891    }
 892
 893    /// <inheritdoc/>
 894    public async Task<List<MapFeatureDto>> ListFeaturesForMapAsync(Guid worldId, Guid mapId, Guid userId)
 895    {
 896        _logger.LogTraceSanitized("User {UserId} listing features for map {MapId}", userId, mapId);
 897
 898        await EnsureWorldMembershipAsync(worldId, userId);
 899        await EnsureMapInWorldAsync(worldId, mapId);
 900
 901        var features = await _db.MapFeatures
 902            .AsNoTracking()
 903            .Where(mf => mf.WorldMapId == mapId)
 904            .OrderBy(mf => mf.MapFeatureId)
 905            .ToListAsync();
 906
 907        return await ToMapFeatureDtosAsync(features);
 908    }
 909
 910    /// <inheritdoc/>
 911    public async Task<MapFeatureDto?> GetFeatureAsync(Guid worldId, Guid mapId, Guid featureId, Guid userId)
 912    {
 913        _logger.LogTraceSanitized("User {UserId} getting feature {FeatureId} on map {MapId}", userId, featureId, mapId);
 914
 915        await EnsureWorldMembershipAsync(worldId, userId);
 916        await EnsureMapInWorldAsync(worldId, mapId);
 917
 918        var feature = await _db.MapFeatures
 919            .AsNoTracking()
 920            .FirstOrDefaultAsync(mf => mf.MapFeatureId == featureId && mf.WorldMapId == mapId);
 921
 922        if (feature == null)
 923        {
 924            return null;
 925        }
 926
 927        return await GetMapFeatureResponseAsync(feature.MapFeatureId, mapId);
 928    }
 929
 930    /// <inheritdoc/>
 931    public async Task<List<MapFeatureSessionReferenceDto>> ListSessionReferencesForFeatureAsync(
 932        Guid worldId,
 933        Guid mapId,
 934        Guid featureId,
 935        Guid userId)
 936    {
 937        _logger.LogTraceSanitized(
 938            "User {UserId} listing session references for feature {FeatureId} on map {MapId}",
 939            userId,
 940            featureId,
 941            mapId);
 942
 943        await EnsureWorldMembershipAsync(worldId, userId);
 944        await EnsureMapInWorldAsync(worldId, mapId);
 945
 946        var featureExists = await _db.MapFeatures
 947            .AsNoTracking()
 948            .AnyAsync(feature => feature.MapFeatureId == featureId && feature.WorldMapId == mapId);
 949
 950        if (!featureExists)
 951        {
 952            throw new InvalidOperationException("Feature not found");
 953        }
 954
 955        var references = await _db.SessionNoteMapFeatures
 956            .AsNoTracking()
 957            .Where(link => link.MapFeatureId == featureId)
 958            .Join(
 959                _db.Articles.AsNoTracking(),
 960                link => link.SessionNoteId,
 961                article => article.Id,
 962                (link, article) => new { link, article })
 963            .Join(
 964                _db.WorldMaps.AsNoTracking(),
 965                joined => joined.link.MapFeature.WorldMapId,
 966                map => map.WorldMapId,
 967                (joined, map) => new { joined.link, joined.article, map.WorldId })
 968            .GroupJoin(
 969                _db.Sessions.AsNoTracking(),
 970                joined => joined.article.SessionId,
 971                session => session.Id,
 972                (joined, sessions) => new { joined.link, joined.article, joined.WorldId, session = sessions.FirstOrDefau
 973            .Where(result =>
 974                result.WorldId == worldId
 975                && result.article.Type == ArticleType.SessionNote
 976                && (result.article.Visibility != ArticleVisibility.Private || result.article.CreatedBy == userId))
 977            .Select(result => new
 978            {
 979                result.link.SessionNoteId,
 980                SessionNoteTitle = result.article.Title,
 981                result.article.SessionId,
 982                SessionName = result.session != null && !string.IsNullOrWhiteSpace(result.session.Name)
 983                    ? result.session.Name
 984                    : null,
 985                SessionDate = result.session != null && result.session.SessionDate.HasValue
 986                    ? result.session.SessionDate
 987                    : result.article.SessionDate,
 988                result.link.CreatedAt,
 989            })
 990            .OrderBy(result => result.SessionDate.HasValue ? 0 : 1)
 991            .ThenBy(result => result.SessionDate)
 992            .ThenBy(result => result.CreatedAt)
 993            .ThenBy(result => result.SessionNoteTitle)
 994            .ThenBy(result => result.SessionNoteId)
 995            .Select(result => new MapFeatureSessionReferenceDto
 996            {
 997                SessionNoteId = result.SessionNoteId,
 998                SessionNoteTitle = result.SessionNoteTitle,
 999                SessionId = result.SessionId,
 1000                SessionName = result.SessionName,
 1001                SessionDate = result.SessionDate,
 1002                CreatedAt = result.CreatedAt,
 1003            })
 1004            .ToListAsync();
 1005
 1006        return references;
 1007    }
 1008
 1009    /// <inheritdoc/>
 1010    public async Task<MapFeatureDto> UpdateFeatureAsync(Guid worldId, Guid mapId, Guid featureId, Guid userId, MapFeatur
 1011    {
 1012        _logger.LogTraceSanitized("User {UserId} updating feature {FeatureId} on map {MapId}", userId, featureId, mapId)
 1013
 1014        await EnsureWorldMembershipAsync(worldId, userId);
 1015        await EnsureMapInWorldAsync(worldId, mapId);
 1016
 1017        var feature = await _db.MapFeatures
 1018            .FirstOrDefaultAsync(mf => mf.MapFeatureId == featureId && mf.WorldMapId == mapId);
 1019
 1020        if (feature == null)
 1021        {
 1022            throw new InvalidOperationException("Feature not found");
 1023        }
 1024
 1025        var layerId = await ResolveRequestedLayerIdAsync(worldId, mapId, dto.LayerId);
 1026        feature.Name = NormalizePinName(dto.Name);
 1027        feature.Color = dto.Color;
 1028        feature.MapLayerId = layerId;
 1029        feature.LinkedArticleId = dto.LinkedArticleId;
 1030
 1031        if (feature.FeatureType == MapFeatureType.Point)
 1032        {
 1033            if (dto.Point == null)
 1034            {
 1035                throw new ArgumentException("Point geometry is required");
 1036            }
 1037
 1038            ValidateNormalizedCoordinates(dto.Point.X, dto.Point.Y);
 1039            feature.X = dto.Point.X;
 1040            feature.Y = dto.Point.Y;
 1041        }
 1042        else if (feature.FeatureType == MapFeatureType.Polygon)
 1043        {
 1044            var normalizedPolygon = NormalizePolygonGeometry(dto.Polygon);
 1045            var nextBlobKey = _mapBlobStore.BuildFeatureGeometryBlobKey(mapId, layerId, feature.MapFeatureId);
 1046            if (!string.Equals(feature.GeometryBlobKey, nextBlobKey, StringComparison.Ordinal))
 1047            {
 1048                if (!string.IsNullOrWhiteSpace(feature.GeometryBlobKey))
 1049                {
 1050                    await _mapBlobStore.DeleteFeatureGeometryAsync(feature.GeometryBlobKey);
 1051                }
 1052
 1053                feature.GeometryBlobKey = nextBlobKey;
 1054            }
 1055
 1056            var geometryJson = JsonSerializer.Serialize(normalizedPolygon, GeometryJsonOptions);
 1057            feature.GeometryETag = await _mapBlobStore.SaveFeatureGeometryAsync(feature.GeometryBlobKey!, geometryJson);
 1058        }
 1059        else
 1060        {
 1061            throw new ArgumentException("Unsupported feature type");
 1062        }
 1063
 1064        await _db.SaveChangesAsync();
 1065        return await GetMapFeatureResponseAsync(feature.MapFeatureId, mapId);
 1066    }
 1067
 1068    /// <inheritdoc/>
 1069    public async Task DeleteFeatureAsync(Guid worldId, Guid mapId, Guid featureId, Guid userId)
 1070    {
 1071        _logger.LogTraceSanitized("User {UserId} deleting feature {FeatureId} on map {MapId}", userId, featureId, mapId)
 1072
 1073        await EnsureWorldMembershipAsync(worldId, userId);
 1074        await EnsureMapInWorldAsync(worldId, mapId);
 1075
 1076        var feature = await _db.MapFeatures
 1077            .FirstOrDefaultAsync(mf => mf.MapFeatureId == featureId && mf.WorldMapId == mapId);
 1078
 1079        if (feature == null)
 1080        {
 1081            throw new InvalidOperationException("Feature not found");
 1082        }
 1083
 1084        if (!string.IsNullOrWhiteSpace(feature.GeometryBlobKey))
 1085        {
 1086            await _mapBlobStore.DeleteFeatureGeometryAsync(feature.GeometryBlobKey);
 1087        }
 1088
 1089        _db.MapFeatures.Remove(feature);
 1090        await _db.SaveChangesAsync();
 1091    }
 1092
 1093    /// <inheritdoc/>
 1094    public async Task AddFeatureToSessionNoteAsync(Guid worldId, Guid sessionNoteId, Guid mapFeatureId, Guid userId)
 1095    {
 1096        _logger.LogTraceSanitized(
 1097            "User {UserId} linking feature {FeatureId} to session note {SessionNoteId} in world {WorldId}",
 1098            userId,
 1099            mapFeatureId,
 1100            sessionNoteId,
 1101            worldId);
 1102
 1103        await EnsureWorldMembershipAsync(worldId, userId);
 1104        _ = await GetSessionNoteArticleAsync(worldId, sessionNoteId);
 1105        _ = await GetMapFeatureInWorldAsync(worldId, mapFeatureId);
 1106
 1107        var exists = await _db.SessionNoteMapFeatures
 1108            .AsNoTracking()
 1109            .AnyAsync(link => link.SessionNoteId == sessionNoteId && link.MapFeatureId == mapFeatureId);
 1110
 1111        if (exists)
 1112        {
 1113            return;
 1114        }
 1115
 1116        _db.SessionNoteMapFeatures.Add(new SessionNoteMapFeature
 1117        {
 1118            SessionNoteId = sessionNoteId,
 1119            MapFeatureId = mapFeatureId,
 1120            CreatedAt = DateTime.UtcNow,
 1121        });
 1122
 1123        await _db.SaveChangesAsync();
 1124    }
 1125
 1126    /// <inheritdoc/>
 1127    public async Task RemoveFeatureFromSessionNoteAsync(Guid worldId, Guid sessionNoteId, Guid mapFeatureId, Guid userId
 1128    {
 1129        _logger.LogTraceSanitized(
 1130            "User {UserId} unlinking feature {FeatureId} from session note {SessionNoteId} in world {WorldId}",
 1131            userId,
 1132            mapFeatureId,
 1133            sessionNoteId,
 1134            worldId);
 1135
 1136        await EnsureWorldMembershipAsync(worldId, userId);
 1137        _ = await GetSessionNoteArticleAsync(worldId, sessionNoteId);
 1138        _ = await GetMapFeatureInWorldAsync(worldId, mapFeatureId);
 1139
 1140        var link = await _db.SessionNoteMapFeatures
 1141            .FirstOrDefaultAsync(existingLink => existingLink.SessionNoteId == sessionNoteId && existingLink.MapFeatureI
 1142
 1143        if (link == null)
 1144        {
 1145            return;
 1146        }
 1147
 1148        _db.SessionNoteMapFeatures.Remove(link);
 1149        await _db.SaveChangesAsync();
 1150    }
 1151
 1152    /// <inheritdoc/>
 1153    public async Task<List<MapFeatureDto>> ListFeaturesForSessionNoteAsync(Guid worldId, Guid sessionNoteId, Guid userId
 1154    {
 1155        _logger.LogTraceSanitized(
 1156            "User {UserId} listing linked features for session note {SessionNoteId} in world {WorldId}",
 1157            userId,
 1158            sessionNoteId,
 1159            worldId);
 1160
 1161        await EnsureWorldMembershipAsync(worldId, userId);
 1162        _ = await GetSessionNoteArticleAsync(worldId, sessionNoteId);
 1163
 1164        var features = await _db.SessionNoteMapFeatures
 1165            .AsNoTracking()
 1166            .Where(link => link.SessionNoteId == sessionNoteId)
 1167            .Join(
 1168                _db.WorldMaps.AsNoTracking(),
 1169                link => link.MapFeature.WorldMapId,
 1170                worldMap => worldMap.WorldMapId,
 1171                (link, worldMap) => new { link.MapFeature, worldMap.WorldId, link.MapFeatureId })
 1172            .Where(result => result.WorldId == worldId)
 1173            .OrderBy(result => result.MapFeatureId)
 1174            .Select(result => result.MapFeature)
 1175            .ToListAsync();
 1176
 1177        return await ToMapFeatureDtosAsync(features);
 1178    }
 1179
 1180    /// <inheritdoc/>
 1181    public async Task SyncSessionNoteMapFeaturesAsync(Guid worldId, Guid sessionNoteId, IEnumerable<Guid> mapFeatureIds,
 1182    {
 1183        _logger.LogTraceSanitized(
 1184            "User {UserId} syncing session-note feature links for session note {SessionNoteId} in world {WorldId}",
 1185            userId,
 1186            sessionNoteId,
 1187            worldId);
 1188
 1189        await EnsureWorldMembershipAsync(worldId, userId);
 1190        _ = await GetSessionNoteArticleAsync(worldId, sessionNoteId);
 1191
 1192        var requestedFeatureIds = mapFeatureIds
 1193            .Where(featureId => featureId != Guid.Empty)
 1194            .Distinct()
 1195            .ToList();
 1196
 1197        if (requestedFeatureIds.Count > 0)
 1198        {
 1199            var validFeatureIds = await _db.MapFeatures
 1200                .AsNoTracking()
 1201                .Join(
 1202                    _db.WorldMaps.AsNoTracking(),
 1203                    feature => feature.WorldMapId,
 1204                    map => map.WorldMapId,
 1205                    (feature, map) => new { feature.MapFeatureId, map.WorldId })
 1206                .Where(result => result.WorldId == worldId && requestedFeatureIds.Contains(result.MapFeatureId))
 1207                .Select(result => result.MapFeatureId)
 1208                .ToListAsync();
 1209
 1210            if (validFeatureIds.Count != requestedFeatureIds.Count)
 1211            {
 1212                throw new InvalidOperationException("Feature not found");
 1213            }
 1214        }
 1215
 1216        var existingLinks = await _db.SessionNoteMapFeatures
 1217            .Where(link => link.SessionNoteId == sessionNoteId)
 1218            .ToListAsync();
 1219
 1220        var requestedFeatureSet = requestedFeatureIds.ToHashSet();
 1221        var existingFeatureSet = existingLinks.Select(link => link.MapFeatureId).ToHashSet();
 1222
 1223        var linksToRemove = existingLinks
 1224            .Where(link => !requestedFeatureSet.Contains(link.MapFeatureId))
 1225            .ToList();
 1226        if (linksToRemove.Count > 0)
 1227        {
 1228            _db.SessionNoteMapFeatures.RemoveRange(linksToRemove);
 1229        }
 1230
 1231        var featureIdsToAdd = requestedFeatureIds
 1232            .Where(featureId => !existingFeatureSet.Contains(featureId))
 1233            .ToList();
 1234        foreach (var featureId in featureIdsToAdd)
 1235        {
 1236            _db.SessionNoteMapFeatures.Add(new SessionNoteMapFeature
 1237            {
 1238                SessionNoteId = sessionNoteId,
 1239                MapFeatureId = featureId,
 1240                CreatedAt = DateTime.UtcNow,
 1241            });
 1242        }
 1243
 1244        await _db.SaveChangesAsync();
 1245    }
 1246
 1247    /// <inheritdoc/>
 1248    public async Task<RequestBasemapUploadResponseDto> RequestBasemapUploadAsync(
 1249        Guid worldId, Guid mapId, Guid userId, RequestBasemapUploadDto dto)
 1250    {
 1251        _logger.LogTraceSanitized("User {UserId} requesting basemap upload for map {MapId}", userId, mapId);
 1252
 1253        if (!AllowedContentTypes.Contains(dto.ContentType))
 1254        {
 1255            throw new ArgumentException(
 1256                $"Content type '{dto.ContentType}' is not supported. Allowed: image/png, image/jpeg, image/webp");
 1257        }
 1258
 1259        var map = await _db.WorldMaps
 1260            .Include(m => m.World)
 1261            .FirstOrDefaultAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 1262
 1263        if (map == null)
 1264        {
 1265            throw new InvalidOperationException("Map not found");
 1266        }
 1267
 1268        if (map.World.OwnerId != userId)
 1269        {
 1270            throw new UnauthorizedAccessException("Only the world owner can upload basemap images");
 1271        }
 1272
 1273        var sasUrl = await _mapBlobStore.GenerateUploadSasUrlAsync(mapId, dto.FileName, dto.ContentType);
 1274        var blobKey = _mapBlobStore.BuildBasemapBlobKey(mapId, dto.FileName);
 1275
 1276        map.BasemapBlobKey = blobKey;
 1277        map.BasemapOriginalFilename = dto.FileName;
 1278        map.BasemapContentType = dto.ContentType;
 1279        map.UpdatedUtc = DateTime.UtcNow;
 1280
 1281        await _db.SaveChangesAsync();
 1282
 1283        _logger.LogTraceSanitized("Stored blob key {BlobKey} for map {MapId}", blobKey, mapId);
 1284
 1285        return new RequestBasemapUploadResponseDto { UploadUrl = sasUrl };
 1286    }
 1287
 1288    /// <inheritdoc/>
 1289    public async Task<MapDto> ConfirmBasemapUploadAsync(Guid worldId, Guid mapId, Guid userId)
 1290    {
 1291        _logger.LogTraceSanitized("User {UserId} confirming basemap upload for map {MapId}", userId, mapId);
 1292
 1293        var map = await _db.WorldMaps
 1294            .Include(m => m.World)
 1295            .FirstOrDefaultAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 1296
 1297        if (map == null)
 1298        {
 1299            throw new InvalidOperationException("Map not found");
 1300        }
 1301
 1302        if (map.World.OwnerId != userId)
 1303        {
 1304            throw new UnauthorizedAccessException("Only the world owner can confirm basemap uploads");
 1305        }
 1306
 1307        if (map.BasemapBlobKey == null)
 1308        {
 1309            throw new InvalidOperationException("No basemap upload was requested for this map");
 1310        }
 1311
 1312        _logger.LogTraceSanitized("Confirmed basemap upload for map {MapId}", mapId);
 1313
 1314        return ToMapDto(map);
 1315    }
 1316
 1317    /// <inheritdoc/>
 1318    public async Task<GetBasemapReadUrlResponseDto> GetBasemapReadUrlAsync(Guid worldId, Guid mapId, Guid userId)
 1319    {
 1320        _logger.LogTraceSanitized("User {UserId} requesting basemap read URL for map {MapId}", userId, mapId);
 1321
 1322        var hasAccess = await _db.Worlds
 1323            .AsNoTracking()
 1324            .AnyAsync(w => w.Id == worldId
 1325                && (w.OwnerId == userId || w.Members.Any(m => m.UserId == userId)));
 1326
 1327        if (!hasAccess)
 1328        {
 1329            throw new UnauthorizedAccessException("World not found or access denied");
 1330        }
 1331
 1332        var map = await _db.WorldMaps
 1333            .AsNoTracking()
 1334            .FirstOrDefaultAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 1335
 1336        if (map == null)
 1337        {
 1338            throw new InvalidOperationException("Map not found");
 1339        }
 1340
 1341        if (string.IsNullOrWhiteSpace(map.BasemapBlobKey))
 1342        {
 1343            throw new InvalidOperationException("Basemap not found for this map");
 1344        }
 1345
 1346        var readUrl = await _mapBlobStore.GenerateReadSasUrlAsync(map.BasemapBlobKey);
 1347        return new GetBasemapReadUrlResponseDto { ReadUrl = readUrl };
 1348    }
 1349
 1350    /// <inheritdoc/>
 1351    public async Task DeleteMapAsync(Guid worldId, Guid mapId, Guid userId)
 1352    {
 1353        _logger.LogTraceSanitized("User {UserId} deleting map {MapId} in world {WorldId}", userId, mapId, worldId);
 1354
 1355        var map = await _db.WorldMaps
 1356            .Include(m => m.World)
 1357            .FirstOrDefaultAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 1358
 1359        if (map == null)
 1360        {
 1361            throw new InvalidOperationException("Map not found");
 1362        }
 1363
 1364        if (map.World.OwnerId != userId)
 1365        {
 1366            throw new UnauthorizedAccessException("Only the world owner can delete maps");
 1367        }
 1368
 1369        await _mapBlobStore.DeleteMapFolderAsync(mapId);
 1370
 1371        _db.WorldMaps.Remove(map);
 1372        await _db.SaveChangesAsync();
 1373    }
 1374
 1375    private async Task EnsureWorldMembershipAsync(Guid worldId, Guid userId)
 1376    {
 1377        var hasAccess = await _db.Worlds
 1378            .AsNoTracking()
 1379            .AnyAsync(w => w.Id == worldId
 1380                && (w.OwnerId == userId || w.Members.Any(m => m.UserId == userId)));
 1381
 1382        if (!hasAccess)
 1383        {
 1384            throw new UnauthorizedAccessException("World not found or access denied");
 1385        }
 1386    }
 1387
 1388    private async Task EnsureMapInWorldAsync(Guid worldId, Guid mapId)
 1389    {
 1390        var mapExists = await _db.WorldMaps
 1391            .AsNoTracking()
 1392            .AnyAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 1393
 1394        if (!mapExists)
 1395        {
 1396            throw new InvalidOperationException("Map not found");
 1397        }
 1398    }
 1399
 1400    private async Task<Guid> ResolveDefaultLayerIdAsync(Guid worldId, Guid mapId)
 1401    {
 1402        var scopeData = await _db.WorldMaps
 1403            .AsNoTracking()
 1404            .Where(m => m.WorldMapId == mapId && m.WorldId == worldId)
 1405            .Select(m => new
 1406            {
 1407                HasArcScope = m.WorldMapArcs.Any(),
 1408                HasCampaignScope = m.WorldMapCampaigns.Any(),
 1409            })
 1410            .FirstOrDefaultAsync();
 1411
 1412        if (scopeData == null)
 1413        {
 1414            throw new InvalidOperationException("Map not found");
 1415        }
 1416
 1417        var defaultLayerName = scopeData.HasArcScope
 1418            ? "Arc"
 1419            : scopeData.HasCampaignScope
 1420                ? "Campaign"
 1421                : "World";
 1422
 1423        var layer = await _db.MapLayers
 1424            .AsNoTracking()
 1425            .FirstOrDefaultAsync(l => l.WorldMapId == mapId && l.Name == defaultLayerName);
 1426
 1427        if (layer == null)
 1428        {
 1429            throw new InvalidOperationException($"Default layer '{defaultLayerName}' not found");
 1430        }
 1431
 1432        return layer.MapLayerId;
 1433    }
 1434
 1435    private async Task<Guid> ResolveRequestedLayerIdAsync(Guid worldId, Guid mapId, Guid layerId)
 1436    {
 1437        await EnsureMapInWorldAsync(worldId, mapId);
 1438
 1439        var layer = await _db.MapLayers
 1440            .AsNoTracking()
 1441            .FirstOrDefaultAsync(l => l.MapLayerId == layerId);
 1442
 1443        if (layer == null)
 1444        {
 1445            throw new ArgumentException("Layer not found");
 1446        }
 1447
 1448        if (layer.WorldMapId != mapId)
 1449        {
 1450            throw new ArgumentException("Layer does not belong to map");
 1451        }
 1452
 1453        return layer.MapLayerId;
 1454    }
 1455
 1456    private static void ValidateNormalizedCoordinates(float x, float y)
 1457    {
 791458        if (!IsNormalizedCoordinate(x) || !IsNormalizedCoordinate(y))
 1459        {
 171460            throw new ArgumentException("X and Y must be normalized between 0 and 1");
 1461        }
 621462    }
 1463
 1464    private static bool IsNormalizedCoordinate(float value) =>
 1491465        !float.IsNaN(value) && !float.IsInfinity(value) && value >= 0f && value <= 1f;
 1466
 1467    private static string? NormalizeAutocompleteQuery(string? query)
 1468    {
 31469        if (string.IsNullOrWhiteSpace(query))
 1470        {
 11471            return null;
 1472        }
 1473
 21474        var collapsed = WhitespaceCollapseRegex.Replace(query.Trim(), " ");
 21475        return collapsed.ToLowerInvariant();
 1476    }
 1477
 1478    private static string? NormalizePinName(string? value)
 1479    {
 521480        if (string.IsNullOrWhiteSpace(value))
 1481        {
 341482            return null;
 1483        }
 1484
 181485        var trimmed = value.Trim();
 181486        if (trimmed.Length > MaxPinNameLength)
 1487        {
 11488            throw new ArgumentException($"Pin name must be {MaxPinNameLength} characters or fewer");
 1489        }
 1490
 171491        return trimmed;
 1492    }
 1493
 1494    private static string NormalizeLayerName(string? value)
 1495    {
 711496        if (string.IsNullOrWhiteSpace(value))
 1497        {
 11498            throw new ArgumentException("Layer name is required");
 1499        }
 1500
 701501        var trimmed = value.Trim();
 701502        if (trimmed.Length > MaxLayerNameLength)
 1503        {
 11504            throw new ArgumentException($"Layer name must be {MaxLayerNameLength} characters or fewer");
 1505        }
 1506
 691507        return trimmed;
 1508    }
 1509
 1510    private static bool IsProtectedDefaultLayer(string layerName) =>
 261511        ProtectedDefaultLayerNames.Contains(layerName);
 1512
 1513    private async Task<Article> GetSessionNoteArticleAsync(Guid worldId, Guid sessionNoteId)
 1514    {
 1515        var sessionNote = await _db.Articles
 1516            .AsNoTracking()
 1517            .FirstOrDefaultAsync(article => article.Id == sessionNoteId && article.WorldId == worldId);
 1518
 1519        if (sessionNote == null)
 1520        {
 1521            throw new InvalidOperationException("Session note not found");
 1522        }
 1523
 1524        if (sessionNote.Type != ArticleType.SessionNote)
 1525        {
 1526            throw new ArgumentException("Article must be a SessionNote");
 1527        }
 1528
 1529        return sessionNote;
 1530    }
 1531
 1532    private async Task<MapFeature> GetMapFeatureInWorldAsync(Guid worldId, Guid mapFeatureId)
 1533    {
 1534        var feature = await _db.MapFeatures
 1535            .AsNoTracking()
 1536            .Join(
 1537                _db.WorldMaps.AsNoTracking(),
 1538                existingFeature => existingFeature.WorldMapId,
 1539                worldMap => worldMap.WorldMapId,
 1540                (existingFeature, worldMap) => new { Feature = existingFeature, worldMap.WorldId })
 1541            .Where(result => result.Feature.MapFeatureId == mapFeatureId)
 1542            .Select(result => new { result.Feature, result.WorldId })
 1543            .FirstOrDefaultAsync();
 1544
 1545        if (feature == null || feature.WorldId != worldId)
 1546        {
 1547            throw new InvalidOperationException("Feature not found");
 1548        }
 1549
 1550        return feature.Feature;
 1551    }
 1552
 1553    private async Task EnsureValidParentChainAsync(Guid mapId, Guid layerId, Guid parentLayerId)
 1554    {
 1555        var visited = new HashSet<Guid>();
 1556        Guid? currentLayerId = parentLayerId;
 1557
 1558        while (currentLayerId.HasValue)
 1559        {
 1560            if (!visited.Add(currentLayerId.Value))
 1561            {
 1562                throw new ArgumentException("Invalid parent chain");
 1563            }
 1564
 1565            if (currentLayerId.Value == layerId)
 1566            {
 1567                throw new ArgumentException("Parent assignment would create a cycle");
 1568            }
 1569
 1570            var currentLayer = await _db.MapLayers
 1571                .AsNoTracking()
 1572                .Where(l => l.WorldMapId == mapId && l.MapLayerId == currentLayerId.Value)
 1573                .Select(l => new
 1574                {
 1575                    l.MapLayerId,
 1576                    l.ParentLayerId,
 1577                })
 1578                .FirstOrDefaultAsync();
 1579
 1580            if (currentLayer == null)
 1581            {
 1582                throw new ArgumentException("Invalid parent chain");
 1583            }
 1584
 1585            currentLayerId = currentLayer.ParentLayerId;
 1586        }
 1587    }
 1588
 1589    private async Task<MapPinResponseDto> GetMapPinResponseAsync(Guid pinId, Guid mapId)
 1590    {
 1591        var pin = await _db.MapFeatures
 1592            .AsNoTracking()
 1593            .Where(mf =>
 1594                mf.MapFeatureId == pinId
 1595                && mf.WorldMapId == mapId
 1596                && mf.FeatureType == MapFeatureType.Point)
 1597            .Select(mf => new
 1598            {
 1599                mf.MapFeatureId,
 1600                mf.WorldMapId,
 1601                mf.MapLayerId,
 1602                mf.Name,
 1603                mf.X,
 1604                mf.Y,
 1605                mf.LinkedArticleId,
 1606            })
 1607            .FirstOrDefaultAsync();
 1608
 1609        if (pin == null)
 1610        {
 1611            throw new InvalidOperationException("Pin not found");
 1612        }
 1613
 1614        LinkedArticleSummaryDto? linkedArticle = null;
 1615        if (pin.LinkedArticleId.HasValue)
 1616        {
 1617            var article = await _db.Articles
 1618                .AsNoTracking()
 1619                .Where(a => a.Id == pin.LinkedArticleId.Value)
 1620                .Select(a => new { a.Id, a.Title })
 1621                .FirstOrDefaultAsync();
 1622
 1623            if (article != null)
 1624            {
 1625                linkedArticle = new LinkedArticleSummaryDto
 1626                {
 1627                    ArticleId = article.Id,
 1628                    Title = article.Title,
 1629                };
 1630            }
 1631        }
 1632
 1633        return new MapPinResponseDto
 1634        {
 1635            PinId = pin.MapFeatureId,
 1636            MapId = pin.WorldMapId,
 1637            LayerId = pin.MapLayerId,
 1638            Name = pin.Name,
 1639            X = pin.X,
 1640            Y = pin.Y,
 1641            LinkedArticle = linkedArticle,
 1642        };
 1643    }
 1644
 1645    private async Task<MapFeatureDto> GetMapFeatureResponseAsync(Guid featureId, Guid mapId)
 1646    {
 1647        var feature = await _db.MapFeatures
 1648            .AsNoTracking()
 1649            .FirstOrDefaultAsync(mf => mf.MapFeatureId == featureId && mf.WorldMapId == mapId);
 1650
 1651        if (feature == null)
 1652        {
 1653            throw new InvalidOperationException("Feature not found");
 1654        }
 1655
 1656        var linkedArticles = await GetLinkedArticleTitlesAsync([feature]);
 1657        return (await ToMapFeatureDtosAsync([feature], linkedArticles)).Single();
 1658    }
 1659
 1660    private async Task<List<MapFeatureDto>> ToMapFeatureDtosAsync(
 1661        IReadOnlyCollection<MapFeature> features,
 1662        Dictionary<Guid, string>? linkedArticles = null)
 1663    {
 1664        linkedArticles ??= await GetLinkedArticleTitlesAsync(features);
 1665        var results = new List<MapFeatureDto>(features.Count);
 1666
 1667        foreach (var feature in features)
 1668        {
 1669            PolygonGeometryDto? polygon = null;
 1670            if (feature.FeatureType == MapFeatureType.Polygon && !string.IsNullOrWhiteSpace(feature.GeometryBlobKey))
 1671            {
 1672                var geometryJson = await _mapBlobStore.LoadFeatureGeometryAsync(feature.GeometryBlobKey);
 1673                polygon = geometryJson == null
 1674                    ? null
 1675                    : JsonSerializer.Deserialize<PolygonGeometryDto>(geometryJson, GeometryJsonOptions);
 1676            }
 1677
 1678            results.Add(new MapFeatureDto
 1679            {
 1680                FeatureId = feature.MapFeatureId,
 1681                MapId = feature.WorldMapId,
 1682                LayerId = feature.MapLayerId,
 1683                FeatureType = feature.FeatureType,
 1684                Name = feature.Name,
 1685                Color = feature.Color,
 1686                LinkedArticleId = feature.LinkedArticleId,
 1687                LinkedArticle = feature.LinkedArticleId.HasValue
 1688                    && linkedArticles.TryGetValue(feature.LinkedArticleId.Value, out var title)
 1689                    ? new LinkedArticleSummaryDto
 1690                    {
 1691                        ArticleId = feature.LinkedArticleId.Value,
 1692                        Title = title,
 1693                    }
 1694                    : null,
 1695                Point = feature.FeatureType == MapFeatureType.Point
 1696                    ? new MapFeaturePointDto
 1697                    {
 1698                        X = feature.X,
 1699                        Y = feature.Y,
 1700                    }
 1701                    : null,
 1702                Polygon = polygon,
 1703                Geometry = feature.FeatureType == MapFeatureType.Polygon && !string.IsNullOrWhiteSpace(feature.GeometryB
 1704                    ? new MapFeatureGeometryReferenceDto
 1705                    {
 1706                        BlobKey = feature.GeometryBlobKey,
 1707                        ETag = feature.GeometryETag,
 1708                    }
 1709                    : null,
 1710            });
 1711        }
 1712
 1713        return results;
 1714    }
 1715
 1716    private async Task<Dictionary<Guid, string>> GetLinkedArticleTitlesAsync(IEnumerable<MapFeature> features)
 1717    {
 1718        var linkedArticleIds = features
 1719            .Where(feature => feature.LinkedArticleId.HasValue)
 1720            .Select(feature => feature.LinkedArticleId!.Value)
 1721            .Distinct()
 1722            .ToList();
 1723
 1724        return linkedArticleIds.Count == 0
 1725            ? []
 1726            : await _db.Articles
 1727                .AsNoTracking()
 1728                .Where(a => linkedArticleIds.Contains(a.Id))
 1729                .ToDictionaryAsync(a => a.Id, a => a.Title);
 1730    }
 1731
 1732    private static PolygonGeometryDto NormalizePolygonGeometry(PolygonGeometryDto? polygon)
 1733    {
 131734        if (polygon == null)
 1735        {
 11736            throw new ArgumentException("Polygon geometry is required");
 1737        }
 1738
 121739        if (!string.Equals(polygon.Type, "Polygon", StringComparison.Ordinal))
 1740        {
 11741            throw new ArgumentException("Polygon geometry type must be 'Polygon'");
 1742        }
 1743
 111744        if (polygon.Coordinates.Count != 1)
 1745        {
 11746            throw new ArgumentException("Polygon must contain exactly one outer ring");
 1747        }
 1748
 101749        var ring = polygon.Coordinates[0];
 101750        if (ring.Count < 3)
 1751        {
 11752            throw new ArgumentException("Polygon must contain at least 3 vertices");
 1753        }
 1754
 621755        foreach (var point in ring)
 1756        {
 231757            if (point.Count != 2)
 1758            {
 11759                throw new ArgumentException("Polygon coordinates must be coordinate pairs");
 1760            }
 1761
 221762            ValidateNormalizedCoordinates(point[0], point[1]);
 1763        }
 1764
 71765        var distinctVertices = ring
 71766            .Select(point => (point[0], point[1]))
 71767            .Distinct()
 71768            .Count();
 1769
 71770        if (distinctVertices < 3)
 1771        {
 11772            throw new ArgumentException("Polygon must contain at least 3 distinct vertices");
 1773        }
 1774
 61775        var normalizedRing = ring
 61776            .Select(point => new List<float> { point[0], point[1] })
 61777            .ToList();
 1778
 61779        var first = normalizedRing[0];
 61780        var last = normalizedRing[^1];
 61781        if (first[0] != last[0] || first[1] != last[1])
 1782        {
 61783            normalizedRing.Add(new List<float> { first[0], first[1] });
 1784        }
 1785
 61786        return new PolygonGeometryDto
 61787        {
 61788            Type = "Polygon",
 61789            Coordinates = [normalizedRing],
 61790        };
 1791    }
 1792
 1793    internal static MapScope ComputeScope(WorldMap map)
 1794    {
 61795        if (map.WorldMapArcs.Any())
 1796        {
 21797            return MapScope.ArcScoped;
 1798        }
 1799
 41800        if (map.WorldMapCampaigns.Any())
 1801        {
 21802            return MapScope.CampaignScoped;
 1803        }
 1804
 21805        return MapScope.WorldScoped;
 1806    }
 1807
 1808    private static MapDto ToMapDto(WorldMap map, string? worldSlug = null) =>
 1281809        new()
 1281810        {
 1281811            WorldMapId = map.WorldMapId,
 1281812            WorldId = map.WorldId,
 1281813            Name = map.Name,
 1281814            HasBasemap = map.BasemapBlobKey != null,
 1281815            BasemapContentType = map.BasemapContentType,
 1281816            BasemapOriginalFilename = map.BasemapOriginalFilename,
 1281817            CreatedUtc = map.CreatedUtc,
 1281818            UpdatedUtc = map.UpdatedUtc,
 1281819            Slug = map.Slug,
 1281820            WorldSlug = worldSlug ?? map.World?.Slug ?? string.Empty
 1281821        };
 1822
 1823    private static MapSummaryDto ToMapSummaryDto(WorldMap map) =>
 31824        new()
 31825        {
 31826            WorldMapId = map.WorldMapId,
 31827            Name = map.Name,
 31828            HasBasemap = map.BasemapBlobKey != null,
 31829            Slug = map.Slug,
 31830            Scope = ComputeScope(map),
 31831            CampaignIds = map.WorldMapCampaigns.Select(c => c.CampaignId).ToList(),
 31832            ArcIds = map.WorldMapArcs.Select(a => a.ArcId).ToList(),
 31833        };
 1834
 1835    public async Task<(Guid Id, string Name)?> GetIdBySlugAsync(Guid worldId, string slug)
 1836    {
 1837        var row = await _db.WorldMaps.AsNoTracking()
 1838            .Where(m => m.WorldId == worldId && m.Slug == slug)
 1839            .Select(m => new { m.WorldMapId, m.Name })
 1840            .FirstOrDefaultAsync();
 1841
 1842        return row == null ? null : (row.WorldMapId, row.Name);
 1843    }
 1844
 1845    public async Task<ServiceResult<string>> UpdateSlugAsync(Guid worldId, Guid mapId, Guid userId, string slug)
 1846    {
 1847        var map = await _db.WorldMaps.AsNoTracking()
 1848            .FirstOrDefaultAsync(m => m.WorldMapId == mapId && m.WorldId == worldId);
 1849
 1850        if (map == null)
 1851            return ServiceResult<string>.NotFound("Map not found");
 1852
 1853        var world = await _db.Worlds.AsNoTracking()
 1854            .FirstOrDefaultAsync(w => w.Id == worldId && w.OwnerId == userId);
 1855
 1856        if (world == null)
 1857            return ServiceResult<string>.Forbidden("Only the world owner may update the map slug");
 1858
 1859        if (!SlugGenerator.IsValidSlug(slug))
 1860            return ServiceResult<string>.ValidationError("SLUG_INVALID");
 1861
 1862        if (_reservedSlugProvider.IsReserved(slug))
 1863            return ServiceResult<string>.ValidationError("SLUG_RESERVED");
 1864
 1865        var existing = await _db.WorldMaps.AsNoTracking()
 1866            .Where(m => m.WorldId == worldId && m.WorldMapId != mapId)
 1867            .Select(m => m.Slug)
 1868            .ToHashSetAsync();
 1869
 1870        var finalSlug = SlugGenerator.GenerateUniqueSiblingSlug(slug, existing, _reservedSlugProvider.All);
 1871
 1872        var tracked = await _db.WorldMaps.FirstAsync(m => m.WorldMapId == mapId);
 1873        tracked.Slug = finalSlug;
 1874        await _db.SaveChangesAsync();
 1875
 1876        return ServiceResult<string>.Success(finalSlug);
 1877    }
 1878
 1879    private async Task<string> GenerateUniqueMapSlugAsync(string name, Guid worldId, Guid? excludeId = null)
 1880    {
 1881        var existing = await _db.WorldMaps.AsNoTracking()
 1882            .Where(m => m.WorldId == worldId && (!excludeId.HasValue || m.WorldMapId != excludeId.Value))
 1883            .Select(m => m.Slug)
 1884            .ToHashSetAsync();
 1885
 1886        return SlugGenerator.GenerateUniqueSiblingSlug(
 1887            SlugGenerator.GenerateSlug(name), existing, _reservedSlugProvider.All);
 1888    }
 1889}