< Summary

Information
Class: Chronicis.Api.Controllers.MapsController
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Controllers/MapsController.cs
Line coverage
100%
Covered lines: 8
Uncovered lines: 0
Coverable lines: 8
Total lines: 883
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%

File(s)

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

#LineLine coverage
 1using Chronicis.Api.Infrastructure;
 2using Chronicis.Api.Models;
 3using Chronicis.Api.Services;
 4using Chronicis.Shared.DTOs;
 5using Chronicis.Shared.DTOs.Maps;
 6using Microsoft.AspNetCore.Authorization;
 7using Microsoft.AspNetCore.Mvc;
 8
 9namespace Chronicis.Api.Controllers;
 10
 11/// <summary>
 12/// API endpoints for WorldMap management (create, list, get, basemap upload).
 13/// </summary>
 14[Route("world/{worldId:guid}/maps")]
 15[Authorize]
 16public class MapsController : ControllerBase
 17{
 18    private readonly IWorldMapService _worldMapService;
 19    private readonly ICurrentUserService _currentUserService;
 20    private readonly ILogger<MapsController> _logger;
 21
 10222    public MapsController(
 10223        IWorldMapService worldMapService,
 10224        ICurrentUserService currentUserService,
 10225        ILogger<MapsController> logger)
 26    {
 10227        _worldMapService = worldMapService;
 10228        _currentUserService = currentUserService;
 10229        _logger = logger;
 10230    }
 31
 32    /// <summary>
 33    /// POST /world/{worldId}/maps — Create a new map with default hidden layers.
 34    /// </summary>
 35    [HttpPost]
 36    public async Task<ActionResult<MapDto>> CreateMap(Guid worldId, [FromBody] MapCreateDto dto)
 37    {
 38        var user = await _currentUserService.GetRequiredUserAsync();
 39
 40        if (dto == null || string.IsNullOrWhiteSpace(dto.Name))
 41        {
 42            return BadRequest(new { error = "Name is required" });
 43        }
 44
 45        _logger.LogTraceSanitized("User {UserId} creating map in world {WorldId}", user.Id, worldId);
 46
 47        try
 48        {
 49            var result = await _worldMapService.CreateMapAsync(worldId, user.Id, dto);
 50            return Ok(result);
 51        }
 52        catch (UnauthorizedAccessException ex)
 53        {
 54            _logger.LogWarningSanitized(ex, "Unauthorized map creation");
 55            return StatusCode(403, new { error = ex.Message });
 56        }
 57    }
 58
 59    /// <summary>
 60    /// GET /world/{worldId}/maps/{mapId} — Get full map metadata.
 61    /// </summary>
 62    [HttpGet("{mapId:guid}")]
 63    public async Task<ActionResult<MapDto>> GetMap(Guid worldId, Guid mapId)
 64    {
 65        var user = await _currentUserService.GetRequiredUserAsync();
 66        _logger.LogTraceSanitized("User {UserId} getting map {MapId}", user.Id, mapId);
 67
 68        var map = await _worldMapService.GetMapAsync(mapId, user.Id);
 69
 70        if (map == null)
 71        {
 72            return NotFound(new { error = "Map not found or access denied" });
 73        }
 74
 75        return Ok(map);
 76    }
 77
 78    /// <summary>
 79    /// GET /world/{worldId}/maps/{mapId}/layers — List map layers ordered by sort order.
 80    /// </summary>
 81    [HttpGet("{mapId:guid}/layers")]
 82    public async Task<ActionResult<IEnumerable<MapLayerDto>>> ListLayers(Guid worldId, Guid mapId)
 83    {
 84        var user = await _currentUserService.GetRequiredUserAsync();
 85        _logger.LogTraceSanitized("User {UserId} listing layers for map {MapId}", user.Id, mapId);
 86
 87        try
 88        {
 89            var layers = await _worldMapService.ListLayersForMapAsync(worldId, mapId, user.Id);
 90            return Ok(layers);
 91        }
 92        catch (UnauthorizedAccessException ex)
 93        {
 94            _logger.LogWarningSanitized(ex, "Unauthorized layer list");
 95            return StatusCode(403, new { error = ex.Message });
 96        }
 97        catch (InvalidOperationException ex)
 98        {
 99            _logger.LogWarningSanitized(ex, "Layer list target not found");
 100            return NotFound(new { error = ex.Message });
 101        }
 102    }
 103
 104    /// <summary>
 105    /// POST /world/{worldId}/maps/{mapId}/layers — Create a custom map layer.
 106    /// </summary>
 107    [HttpPost("{mapId:guid}/layers")]
 108    public async Task<ActionResult<MapLayerDto>> CreateLayer(
 109        Guid worldId,
 110        Guid mapId,
 111        [FromBody] CreateLayerRequest request)
 112    {
 113        var user = await _currentUserService.GetRequiredUserAsync();
 114
 115        if (request == null || string.IsNullOrWhiteSpace(request.Name))
 116        {
 117            return BadRequest(new { error = "Layer name is required" });
 118        }
 119
 120        _logger.LogTraceSanitized("User {UserId} creating layer on map {MapId}", user.Id, mapId);
 121
 122        try
 123        {
 124            var created = await _worldMapService.CreateLayer(worldId, mapId, user.Id, request.Name, request.ParentLayerI
 125            return Ok(created);
 126        }
 127        catch (ArgumentException ex)
 128        {
 129            _logger.LogWarningSanitized(ex, "Invalid create layer request");
 130            return BadRequest(new { error = ex.Message });
 131        }
 132        catch (UnauthorizedAccessException ex)
 133        {
 134            _logger.LogWarningSanitized(ex, "Unauthorized create layer request");
 135            return Forbid();
 136        }
 137    }
 138
 139    /// <summary>
 140    /// PUT /world/{worldId}/maps/{mapId}/layers/{layerId}/rename — Rename a custom map layer.
 141    /// </summary>
 142    [HttpPut("{mapId:guid}/layers/{layerId:guid}/rename")]
 143    public async Task<IActionResult> RenameLayer(
 144        Guid worldId,
 145        Guid mapId,
 146        Guid layerId,
 147        [FromBody] RenameLayerRequest request)
 148    {
 149        var user = await _currentUserService.GetRequiredUserAsync();
 150
 151        if (request == null || string.IsNullOrWhiteSpace(request.Name))
 152        {
 153            return BadRequest(new { error = "Layer name is required" });
 154        }
 155
 156        _logger.LogTraceSanitized("User {UserId} renaming layer {LayerId} on map {MapId}", user.Id, layerId, mapId);
 157
 158        try
 159        {
 160            await _worldMapService.RenameLayer(worldId, mapId, user.Id, layerId, request.Name);
 161            return NoContent();
 162        }
 163        catch (ArgumentException ex)
 164        {
 165            _logger.LogWarningSanitized(ex, "Invalid rename layer request");
 166            return BadRequest(new { error = ex.Message });
 167        }
 168        catch (UnauthorizedAccessException ex)
 169        {
 170            _logger.LogWarningSanitized(ex, "Unauthorized rename layer request");
 171            return Forbid();
 172        }
 173    }
 174
 175    /// <summary>
 176    /// PUT /world/{worldId}/maps/{mapId}/layers/{layerId}/parent — Assign or clear layer parent.
 177    /// </summary>
 178    [HttpPut("{mapId:guid}/layers/{layerId:guid}/parent")]
 179    public async Task<IActionResult> SetLayerParent(
 180        Guid worldId,
 181        Guid mapId,
 182        Guid layerId,
 183        [FromBody] SetLayerParentRequest request)
 184    {
 185        var user = await _currentUserService.GetRequiredUserAsync();
 186
 187        if (request == null)
 188        {
 189            return BadRequest(new { error = "Invalid request body" });
 190        }
 191
 192        _logger.LogTraceSanitized("User {UserId} setting parent for layer {LayerId} on map {MapId}", user.Id, layerId, m
 193
 194        try
 195        {
 196            await _worldMapService.SetLayerParentAsync(worldId, mapId, user.Id, layerId, request.ParentLayerId);
 197            return NoContent();
 198        }
 199        catch (ArgumentException ex)
 200        {
 201            _logger.LogWarningSanitized(ex, "Invalid set layer parent request");
 202            return BadRequest(new { error = ex.Message });
 203        }
 204        catch (UnauthorizedAccessException ex)
 205        {
 206            _logger.LogWarningSanitized(ex, "Unauthorized set layer parent request");
 207            return Forbid();
 208        }
 209    }
 210
 211    /// <summary>
 212    /// DELETE /world/{worldId}/maps/{mapId}/layers/{layerId} — Delete a custom map layer.
 213    /// </summary>
 214    [HttpDelete("{mapId:guid}/layers/{layerId:guid}")]
 215    public async Task<IActionResult> DeleteLayer(Guid worldId, Guid mapId, Guid layerId)
 216    {
 217        var user = await _currentUserService.GetRequiredUserAsync();
 218        _logger.LogTraceSanitized("User {UserId} deleting layer {LayerId} on map {MapId}", user.Id, layerId, mapId);
 219
 220        try
 221        {
 222            await _worldMapService.DeleteLayer(worldId, mapId, user.Id, layerId);
 223            return NoContent();
 224        }
 225        catch (ArgumentException ex)
 226        {
 227            _logger.LogWarningSanitized(ex, "Invalid delete layer request");
 228            return BadRequest(new { error = ex.Message });
 229        }
 230        catch (UnauthorizedAccessException ex)
 231        {
 232            _logger.LogWarningSanitized(ex, "Unauthorized delete layer request");
 233            return Forbid();
 234        }
 235    }
 236
 237    /// <summary>
 238    /// PUT /world/{worldId}/maps/{mapId} — Update map metadata.
 239    /// </summary>
 240    [HttpPut("{mapId:guid}")]
 241    public async Task<ActionResult<MapDto>> UpdateMap(Guid worldId, Guid mapId, [FromBody] MapUpdateDto dto)
 242    {
 243        var user = await _currentUserService.GetRequiredUserAsync();
 244
 245        if (dto == null || string.IsNullOrWhiteSpace(dto.Name))
 246        {
 247            return BadRequest(new { error = "Name is required" });
 248        }
 249
 250        _logger.LogTraceSanitized("User {UserId} updating map {MapId}", user.Id, mapId);
 251
 252        try
 253        {
 254            var updated = await _worldMapService.UpdateMapAsync(worldId, mapId, user.Id, dto);
 255            return Ok(updated);
 256        }
 257        catch (UnauthorizedAccessException ex)
 258        {
 259            _logger.LogWarningSanitized(ex, "Unauthorized map update");
 260            return StatusCode(403, new { error = ex.Message });
 261        }
 262        catch (InvalidOperationException ex)
 263        {
 264            _logger.LogWarningSanitized(ex, "Map update target not found");
 265            return NotFound(new { error = ex.Message });
 266        }
 267    }
 268
 269    /// <summary>
 270    /// PUT /world/{worldId}/maps/{mapId}/layers/{layerId} — Update layer visibility.
 271    /// </summary>
 272    [HttpPut("{mapId:guid}/layers/{layerId:guid}")]
 273    public async Task<IActionResult> UpdateLayerVisibility(
 274        Guid worldId,
 275        Guid mapId,
 276        Guid layerId,
 277        [FromBody] UpdateLayerVisibilityRequest request)
 278    {
 279        var user = await _currentUserService.GetRequiredUserAsync();
 280
 281        if (request == null)
 282        {
 283            return BadRequest(new { error = "Invalid request body" });
 284        }
 285
 286        _logger.LogTraceSanitized("User {UserId} updating visibility for layer {LayerId} on map {MapId}", user.Id, layer
 287
 288        try
 289        {
 290            await _worldMapService.UpdateLayerVisibility(worldId, mapId, layerId, user.Id, request.IsEnabled);
 291            return NoContent();
 292        }
 293        catch (ArgumentException ex)
 294        {
 295            _logger.LogWarningSanitized(ex, "Invalid layer visibility update request");
 296            return BadRequest(new { error = ex.Message });
 297        }
 298        catch (UnauthorizedAccessException ex)
 299        {
 300            _logger.LogWarningSanitized(ex, "Unauthorized layer visibility update");
 301            return Forbid();
 302        }
 303    }
 304
 305    /// <summary>
 306    /// PUT /world/{worldId}/maps/{mapId}/layers/reorder — Reorder layers by full ordered layer ID list.
 307    /// </summary>
 308    [HttpPut("{mapId:guid}/layers/reorder")]
 309    public async Task<IActionResult> ReorderLayers(
 310        Guid worldId,
 311        Guid mapId,
 312        [FromBody] ReorderLayersRequest request)
 313    {
 314        var user = await _currentUserService.GetRequiredUserAsync();
 315
 316        if (request == null)
 317        {
 318            return BadRequest(new { error = "Invalid request body" });
 319        }
 320
 321        _logger.LogTraceSanitized("User {UserId} reordering layers on map {MapId}", user.Id, mapId);
 322
 323        try
 324        {
 325            await _worldMapService.ReorderLayers(worldId, mapId, user.Id, request.LayerIds);
 326            return NoContent();
 327        }
 328        catch (ArgumentException ex)
 329        {
 330            _logger.LogWarningSanitized(ex, "Invalid layer reorder request");
 331            return BadRequest(new { error = ex.Message });
 332        }
 333        catch (UnauthorizedAccessException ex)
 334        {
 335            _logger.LogWarningSanitized(ex, "Unauthorized layer reorder request");
 336            return Forbid();
 337        }
 338    }
 339
 340    /// <summary>
 341    /// GET /world/{worldId}/maps — List all maps for a world, sorted by name.
 342    /// </summary>
 343    [HttpGet]
 344    public async Task<ActionResult<IEnumerable<MapSummaryDto>>> ListMapsForWorld(Guid worldId)
 345    {
 346        var user = await _currentUserService.GetRequiredUserAsync();
 347        _logger.LogTraceSanitized("User {UserId} listing maps for world {WorldId}", user.Id, worldId);
 348
 349        try
 350        {
 351            var maps = await _worldMapService.ListMapsForWorldAsync(worldId, user.Id);
 352            return Ok(maps);
 353        }
 354        catch (UnauthorizedAccessException ex)
 355        {
 356            _logger.LogWarningSanitized(ex, "Unauthorized map list");
 357            return StatusCode(403, new { error = ex.Message });
 358        }
 359    }
 360
 361    /// <summary>
 362    /// GET /world/{worldId}/maps/autocomplete?query=... — List minimal map suggestions for editor autocomplete.
 363    /// </summary>
 364    [HttpGet("autocomplete")]
 365    public async Task<ActionResult<IEnumerable<MapAutocompleteDto>>> AutocompleteMaps(Guid worldId, [FromQuery] string? 
 366    {
 367        var user = await _currentUserService.GetRequiredUserAsync();
 368        _logger.LogTraceSanitized("User {UserId} requesting map autocomplete for world {WorldId}", user.Id, worldId);
 369
 370        try
 371        {
 372            var maps = await _worldMapService.SearchMapsForWorldAsync(worldId, user.Id, query);
 373            return Ok(maps);
 374        }
 375        catch (UnauthorizedAccessException ex)
 376        {
 377            _logger.LogWarningSanitized(ex, "Unauthorized map autocomplete request");
 378            return StatusCode(403, new { error = ex.Message });
 379        }
 380    }
 381
 382    /// <summary>
 383    /// POST /world/{worldId}/maps/{mapId}/pins — Create a pin on a map.
 384    /// </summary>
 385    [HttpPost("{mapId:guid}/pins")]
 386    public async Task<ActionResult<MapPinResponseDto>> CreatePin(
 387        Guid worldId, Guid mapId, [FromBody] MapPinCreateDto dto)
 388    {
 389        var user = await _currentUserService.GetRequiredUserAsync();
 390
 391        if (dto == null)
 392        {
 393            return BadRequest(new { error = "Invalid request body" });
 394        }
 395
 396        _logger.LogTraceSanitized("User {UserId} creating pin on map {MapId}", user.Id, mapId);
 397
 398        try
 399        {
 400            var pin = await _worldMapService.CreatePinAsync(worldId, mapId, user.Id, dto);
 401            return Ok(pin);
 402        }
 403        catch (UnauthorizedAccessException ex)
 404        {
 405            _logger.LogWarningSanitized(ex, "Unauthorized pin create");
 406            return StatusCode(403, new { error = ex.Message });
 407        }
 408        catch (ArgumentException ex)
 409        {
 410            _logger.LogWarningSanitized(ex, "Invalid pin create request");
 411            return BadRequest(new { error = ex.Message });
 412        }
 413        catch (InvalidOperationException ex)
 414        {
 415            _logger.LogWarningSanitized(ex, "Pin create target not found");
 416            return NotFound(new { error = ex.Message });
 417        }
 418    }
 419
 420    /// <summary>
 421    /// GET /world/{worldId}/maps/{mapId}/pins — List pins for a map.
 422    /// </summary>
 423    [HttpGet("{mapId:guid}/pins")]
 424    public async Task<ActionResult<IEnumerable<MapPinResponseDto>>> ListPins(Guid worldId, Guid mapId)
 425    {
 426        var user = await _currentUserService.GetRequiredUserAsync();
 427        _logger.LogTraceSanitized("User {UserId} listing pins for map {MapId}", user.Id, mapId);
 428
 429        try
 430        {
 431            var pins = await _worldMapService.ListPinsForMapAsync(worldId, mapId, user.Id);
 432            return Ok(pins);
 433        }
 434        catch (UnauthorizedAccessException ex)
 435        {
 436            _logger.LogWarningSanitized(ex, "Unauthorized pin list");
 437            return StatusCode(403, new { error = ex.Message });
 438        }
 439        catch (InvalidOperationException ex)
 440        {
 441            _logger.LogWarningSanitized(ex, "Pin list target not found");
 442            return NotFound(new { error = ex.Message });
 443        }
 444    }
 445
 446    /// <summary>
 447    /// PATCH /world/{worldId}/maps/{mapId}/pins/{pinId} — Update pin position.
 448    /// </summary>
 449    [HttpPatch("{mapId:guid}/pins/{pinId:guid}")]
 450    public async Task<ActionResult<MapPinResponseDto>> UpdatePinPosition(
 451        Guid worldId, Guid mapId, Guid pinId, [FromBody] MapPinPositionUpdateDto dto)
 452    {
 453        var user = await _currentUserService.GetRequiredUserAsync();
 454
 455        if (dto == null)
 456        {
 457            return BadRequest(new { error = "Invalid request body" });
 458        }
 459
 460        _logger.LogTraceSanitized("User {UserId} updating pin {PinId} on map {MapId}", user.Id, pinId, mapId);
 461
 462        try
 463        {
 464            var pin = await _worldMapService.UpdatePinPositionAsync(worldId, mapId, pinId, user.Id, dto);
 465            return Ok(pin);
 466        }
 467        catch (UnauthorizedAccessException ex)
 468        {
 469            _logger.LogWarningSanitized(ex, "Unauthorized pin update");
 470            return StatusCode(403, new { error = ex.Message });
 471        }
 472        catch (ArgumentException ex)
 473        {
 474            _logger.LogWarningSanitized(ex, "Invalid pin update request");
 475            return BadRequest(new { error = ex.Message });
 476        }
 477        catch (InvalidOperationException ex)
 478        {
 479            _logger.LogWarningSanitized(ex, "Pin update target not found");
 480            return NotFound(new { error = ex.Message });
 481        }
 482    }
 483
 484    /// <summary>
 485    /// DELETE /world/{worldId}/maps/{mapId}/pins/{pinId} — Delete a pin from a map.
 486    /// </summary>
 487    [HttpDelete("{mapId:guid}/pins/{pinId:guid}")]
 488    public async Task<IActionResult> DeletePin(Guid worldId, Guid mapId, Guid pinId)
 489    {
 490        var user = await _currentUserService.GetRequiredUserAsync();
 491        _logger.LogTraceSanitized("User {UserId} deleting pin {PinId} on map {MapId}", user.Id, pinId, mapId);
 492
 493        try
 494        {
 495            await _worldMapService.DeletePinAsync(worldId, mapId, pinId, user.Id);
 496            return NoContent();
 497        }
 498        catch (UnauthorizedAccessException ex)
 499        {
 500            _logger.LogWarningSanitized(ex, "Unauthorized pin delete");
 501            return StatusCode(403, new { error = ex.Message });
 502        }
 503        catch (InvalidOperationException ex)
 504        {
 505            _logger.LogWarningSanitized(ex, "Pin delete target not found");
 506            return NotFound(new { error = ex.Message });
 507        }
 508    }
 509
 510    /// <summary>
 511    /// GET /world/{worldId}/maps/features/autocomplete?query=... — List minimal map feature suggestions for editor auto
 512    /// </summary>
 513    [HttpGet("features/autocomplete")]
 514    public async Task<ActionResult<IEnumerable<MapFeatureAutocompleteDto>>> AutocompleteFeatures(Guid worldId, [FromQuer
 515    {
 516        var user = await _currentUserService.GetRequiredUserAsync();
 517        _logger.LogTraceSanitized("User {UserId} requesting feature autocomplete for world {WorldId}", user.Id, worldId)
 518
 519        try
 520        {
 521            var features = await _worldMapService.SearchMapFeaturesForWorldAsync(worldId, user.Id, query);
 522            return Ok(features);
 523        }
 524        catch (UnauthorizedAccessException ex)
 525        {
 526            _logger.LogWarningSanitized(ex, "Unauthorized feature autocomplete request");
 527            return StatusCode(403, new { error = ex.Message });
 528        }
 529    }
 530
 531    /// <summary>
 532    /// GET /world/{worldId}/maps/{mapId}/features/autocomplete?query=... — List minimal map feature suggestions for a s
 533    /// </summary>
 534    [HttpGet("{mapId:guid}/features/autocomplete")]
 535    public async Task<ActionResult<IEnumerable<MapFeatureAutocompleteDto>>> AutocompleteFeaturesForMap(
 536        Guid worldId,
 537        Guid mapId,
 538        [FromQuery] string? query = null)
 539    {
 540        var user = await _currentUserService.GetRequiredUserAsync();
 541        _logger.LogTraceSanitized(
 542            "User {UserId} requesting feature autocomplete for map {MapId} in world {WorldId}",
 543            user.Id,
 544            mapId,
 545            worldId);
 546
 547        try
 548        {
 549            var features = await _worldMapService.SearchMapFeaturesForMapAsync(worldId, mapId, user.Id, query);
 550            return Ok(features);
 551        }
 552        catch (UnauthorizedAccessException ex)
 553        {
 554            _logger.LogWarningSanitized(ex, "Unauthorized map-scoped feature autocomplete request");
 555            return StatusCode(403, new { error = ex.Message });
 556        }
 557        catch (InvalidOperationException ex)
 558        {
 559            _logger.LogWarningSanitized(ex, "Map-scoped feature autocomplete target not found");
 560            return NotFound(new { error = ex.Message });
 561        }
 562    }
 563
 564    /// <summary>
 565    /// POST /world/{worldId}/maps/{mapId}/features — Create an additive map feature.
 566    /// </summary>
 567    [HttpPost("{mapId:guid}/features")]
 568    public async Task<ActionResult<MapFeatureDto>> CreateFeature(
 569        Guid worldId,
 570        Guid mapId,
 571        [FromBody] MapFeatureCreateDto dto)
 572    {
 573        var user = await _currentUserService.GetRequiredUserAsync();
 574
 575        if (dto == null)
 576        {
 577            return BadRequest(new { error = "Invalid request body" });
 578        }
 579
 580        try
 581        {
 582            var feature = await _worldMapService.CreateFeatureAsync(worldId, mapId, user.Id, dto);
 583            return Ok(feature);
 584        }
 585        catch (UnauthorizedAccessException ex)
 586        {
 587            _logger.LogWarningSanitized(ex, "Unauthorized feature create");
 588            return StatusCode(403, new { error = ex.Message });
 589        }
 590        catch (ArgumentException ex)
 591        {
 592            _logger.LogWarningSanitized(ex, "Invalid feature create request");
 593            return BadRequest(new { error = ex.Message });
 594        }
 595        catch (InvalidOperationException ex)
 596        {
 597            _logger.LogWarningSanitized(ex, "Feature create target not found");
 598            return NotFound(new { error = ex.Message });
 599        }
 600    }
 601
 602    /// <summary>
 603    /// GET /world/{worldId}/maps/{mapId}/features — List additive map features.
 604    /// </summary>
 605    [HttpGet("{mapId:guid}/features")]
 606    public async Task<ActionResult<IEnumerable<MapFeatureDto>>> ListFeatures(Guid worldId, Guid mapId)
 607    {
 608        var user = await _currentUserService.GetRequiredUserAsync();
 609
 610        try
 611        {
 612            var features = await _worldMapService.ListFeaturesForMapAsync(worldId, mapId, user.Id);
 613            return Ok(features);
 614        }
 615        catch (UnauthorizedAccessException ex)
 616        {
 617            _logger.LogWarningSanitized(ex, "Unauthorized feature list");
 618            return StatusCode(403, new { error = ex.Message });
 619        }
 620        catch (InvalidOperationException ex)
 621        {
 622            _logger.LogWarningSanitized(ex, "Feature list target not found");
 623            return NotFound(new { error = ex.Message });
 624        }
 625    }
 626
 627    /// <summary>
 628    /// GET /world/{worldId}/maps/{mapId}/features/{featureId} — Get a single map feature.
 629    /// </summary>
 630    [HttpGet("{mapId:guid}/features/{featureId:guid}")]
 631    public async Task<ActionResult<MapFeatureDto>> GetFeature(Guid worldId, Guid mapId, Guid featureId)
 632    {
 633        var user = await _currentUserService.GetRequiredUserAsync();
 634
 635        try
 636        {
 637            var feature = await _worldMapService.GetFeatureAsync(worldId, mapId, featureId, user.Id);
 638            return feature == null
 639                ? NotFound(new { error = "Feature not found" })
 640                : Ok(feature);
 641        }
 642        catch (UnauthorizedAccessException ex)
 643        {
 644            _logger.LogWarningSanitized(ex, "Unauthorized feature get");
 645            return StatusCode(403, new { error = ex.Message });
 646        }
 647        catch (InvalidOperationException ex)
 648        {
 649            _logger.LogWarningSanitized(ex, "Feature get target not found");
 650            return NotFound(new { error = ex.Message });
 651        }
 652    }
 653
 654    /// <summary>
 655    /// GET /world/{worldId}/maps/{mapId}/features/{featureId}/session-references — List session-note references for a m
 656    /// </summary>
 657    [HttpGet("{mapId:guid}/features/{featureId:guid}/session-references")]
 658    public async Task<ActionResult<IEnumerable<MapFeatureSessionReferenceDto>>> ListFeatureSessionReferences(
 659        Guid worldId,
 660        Guid mapId,
 661        Guid featureId)
 662    {
 663        var user = await _currentUserService.GetRequiredUserAsync();
 664
 665        try
 666        {
 667            var references = await _worldMapService.ListSessionReferencesForFeatureAsync(worldId, mapId, featureId, user
 668            return Ok(references);
 669        }
 670        catch (UnauthorizedAccessException ex)
 671        {
 672            _logger.LogWarningSanitized(ex, "Unauthorized feature session reference list");
 673            return StatusCode(403, new { error = ex.Message });
 674        }
 675        catch (InvalidOperationException ex)
 676        {
 677            _logger.LogWarningSanitized(ex, "Feature session reference list target not found");
 678            return NotFound(new { error = ex.Message });
 679        }
 680    }
 681
 682    /// <summary>
 683    /// PUT /world/{worldId}/maps/{mapId}/features/{featureId} — Replace a map feature.
 684    /// </summary>
 685    [HttpPut("{mapId:guid}/features/{featureId:guid}")]
 686    public async Task<ActionResult<MapFeatureDto>> UpdateFeature(
 687        Guid worldId,
 688        Guid mapId,
 689        Guid featureId,
 690        [FromBody] MapFeatureUpdateDto dto)
 691    {
 692        var user = await _currentUserService.GetRequiredUserAsync();
 693
 694        if (dto == null)
 695        {
 696            return BadRequest(new { error = "Invalid request body" });
 697        }
 698
 699        try
 700        {
 701            var feature = await _worldMapService.UpdateFeatureAsync(worldId, mapId, featureId, user.Id, dto);
 702            return Ok(feature);
 703        }
 704        catch (UnauthorizedAccessException ex)
 705        {
 706            _logger.LogWarningSanitized(ex, "Unauthorized feature update");
 707            return StatusCode(403, new { error = ex.Message });
 708        }
 709        catch (ArgumentException ex)
 710        {
 711            _logger.LogWarningSanitized(ex, "Invalid feature update request");
 712            return BadRequest(new { error = ex.Message });
 713        }
 714        catch (InvalidOperationException ex)
 715        {
 716            _logger.LogWarningSanitized(ex, "Feature update target not found");
 717            return NotFound(new { error = ex.Message });
 718        }
 719    }
 720
 721    /// <summary>
 722    /// DELETE /world/{worldId}/maps/{mapId}/features/{featureId} — Delete a map feature.
 723    /// </summary>
 724    [HttpDelete("{mapId:guid}/features/{featureId:guid}")]
 725    public async Task<IActionResult> DeleteFeature(Guid worldId, Guid mapId, Guid featureId)
 726    {
 727        var user = await _currentUserService.GetRequiredUserAsync();
 728
 729        try
 730        {
 731            await _worldMapService.DeleteFeatureAsync(worldId, mapId, featureId, user.Id);
 732            return NoContent();
 733        }
 734        catch (UnauthorizedAccessException ex)
 735        {
 736            _logger.LogWarningSanitized(ex, "Unauthorized feature delete");
 737            return StatusCode(403, new { error = ex.Message });
 738        }
 739        catch (InvalidOperationException ex)
 740        {
 741            _logger.LogWarningSanitized(ex, "Feature delete target not found");
 742            return NotFound(new { error = ex.Message });
 743        }
 744    }
 745
 746    /// <summary>
 747    /// DELETE /world/{worldId}/maps/{mapId} — Permanently delete map metadata and all map blobs.
 748    /// </summary>
 749    [HttpDelete("{mapId:guid}")]
 750    public async Task<IActionResult> DeleteMap(Guid worldId, Guid mapId)
 751    {
 752        var user = await _currentUserService.GetRequiredUserAsync();
 753        _logger.LogTraceSanitized("User {UserId} deleting map {MapId}", user.Id, mapId);
 754
 755        try
 756        {
 757            await _worldMapService.DeleteMapAsync(worldId, mapId, user.Id);
 758            return NoContent();
 759        }
 760        catch (UnauthorizedAccessException ex)
 761        {
 762            _logger.LogWarningSanitized(ex, "Unauthorized map delete");
 763            return StatusCode(403, new { error = ex.Message });
 764        }
 765        catch (InvalidOperationException ex)
 766        {
 767            _logger.LogWarningSanitized(ex, "Map delete target not found");
 768            return NotFound(new { error = ex.Message });
 769        }
 770    }
 771
 772    /// <summary>
 773    /// POST /world/{worldId}/maps/{mapId}/request-basemap-upload — Generate a SAS URL for uploading a basemap.
 774    /// </summary>
 775    [HttpPost("{mapId:guid}/request-basemap-upload")]
 776    public async Task<ActionResult<RequestBasemapUploadResponseDto>> RequestBasemapUpload(
 777        Guid worldId, Guid mapId, [FromBody] RequestBasemapUploadDto dto)
 778    {
 779        var user = await _currentUserService.GetRequiredUserAsync();
 780
 781        if (dto == null)
 782        {
 783            return BadRequest(new { error = "Invalid request body" });
 784        }
 785
 786        _logger.LogTraceSanitized("User {UserId} requesting basemap upload for map {MapId}", user.Id, mapId);
 787
 788        try
 789        {
 790            var result = await _worldMapService.RequestBasemapUploadAsync(worldId, mapId, user.Id, dto);
 791            return Ok(result);
 792        }
 793        catch (UnauthorizedAccessException ex)
 794        {
 795            _logger.LogWarningSanitized(ex, "Unauthorized basemap upload request");
 796            return StatusCode(403, new { error = ex.Message });
 797        }
 798        catch (ArgumentException ex)
 799        {
 800            _logger.LogWarningSanitized(ex, "Invalid basemap upload request");
 801            return BadRequest(new { error = ex.Message });
 802        }
 803        catch (InvalidOperationException ex)
 804        {
 805            _logger.LogWarningSanitized(ex, "Map not found for basemap upload");
 806            return NotFound(new { error = ex.Message });
 807        }
 808    }
 809
 810    /// <summary>
 811    /// POST /world/{worldId}/maps/{mapId}/confirm-basemap-upload — Confirm the basemap upload completed.
 812    /// Filename and content-type were already persisted during the SAS request; no body required.
 813    /// </summary>
 814    [HttpPost("{mapId:guid}/confirm-basemap-upload")]
 815    public async Task<ActionResult<MapDto>> ConfirmBasemapUpload(Guid worldId, Guid mapId)
 816    {
 817        var user = await _currentUserService.GetRequiredUserAsync();
 818        _logger.LogTraceSanitized("User {UserId} confirming basemap upload for map {MapId}", user.Id, mapId);
 819
 820        try
 821        {
 822            var result = await _worldMapService.ConfirmBasemapUploadAsync(worldId, mapId, user.Id);
 823            return Ok(result);
 824        }
 825        catch (UnauthorizedAccessException ex)
 826        {
 827            _logger.LogWarningSanitized(ex, "Unauthorized basemap confirm");
 828            return StatusCode(403, new { error = ex.Message });
 829        }
 830        catch (InvalidOperationException ex)
 831        {
 832            _logger.LogWarningSanitized(ex, "Map not found for basemap confirm");
 833            return NotFound(new { error = ex.Message });
 834        }
 835    }
 836
 837    /// <summary>
 838    /// GET /world/{worldId}/maps/{mapId}/basemap — Get short-lived SAS read URL for basemap.
 839    /// </summary>
 840    [HttpGet("{mapId:guid}/basemap")]
 841    public async Task<ActionResult<GetBasemapReadUrlResponseDto>> GetBasemapReadUrl(Guid worldId, Guid mapId)
 842    {
 843        var user = await _currentUserService.GetRequiredUserAsync();
 844        _logger.LogTraceSanitized("User {UserId} requesting basemap read URL for map {MapId}", user.Id, mapId);
 845
 846        try
 847        {
 848            var result = await _worldMapService.GetBasemapReadUrlAsync(worldId, mapId, user.Id);
 849            return Ok(result);
 850        }
 851        catch (UnauthorizedAccessException ex)
 852        {
 853            _logger.LogWarningSanitized(ex, "Unauthorized basemap read request");
 854            return StatusCode(403, new { error = ex.Message });
 855        }
 856        catch (InvalidOperationException ex)
 857        {
 858            _logger.LogWarningSanitized(ex, "Basemap read request not found");
 859            return NotFound(new { error = ex.Message });
 860        }
 861    }
 862
 863    /// <summary>
 864    /// PUT /world/{worldId}/maps/{mapId}/slug - Update a map's slug.
 865    /// </summary>
 866    [HttpPut("{mapId:guid}/slug")]
 867    public async Task<ActionResult<SlugUpdateResponseDto>> UpdateMapSlug(Guid worldId, Guid mapId, [FromBody] SlugUpdate
 868    {
 869        var user = await _currentUserService.GetRequiredUserAsync();
 870
 871        var result = await _worldMapService.UpdateSlugAsync(worldId, mapId, user.Id, dto.Slug);
 872
 873        return result.Status switch
 874        {
 875            ServiceStatus.Success => Ok(new SlugUpdateResponseDto { Slug = result.Value! }),
 876            ServiceStatus.NotFound => NotFound(new { error = "Map not found" }),
 877            ServiceStatus.Forbidden => StatusCode(403, new { error = result.ErrorMessage }),
 878            ServiceStatus.ValidationError when result.ErrorMessage == "SLUG_RESERVED" =>
 879                BadRequest(new { error = "SLUG_RESERVED" }),
 880            _ => BadRequest(new { error = result.ErrorMessage })
 881        };
 882    }
 883}