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