< Summary

Information
Class: Chronicis.Api.Controllers.WorldsController
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Controllers/WorldsController.cs
Line coverage
100%
Covered lines: 18
Uncovered lines: 0
Coverable lines: 18
Total lines: 350
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/WorldsController.cs

#LineLine coverage
 1using Chronicis.Api.Infrastructure;
 2using Chronicis.Api.Models;
 3using Chronicis.Api.Services;
 4using Chronicis.Shared.DTOs;
 5using Microsoft.AspNetCore.Authorization;
 6using Microsoft.AspNetCore.Mvc;
 7
 8namespace Chronicis.Api.Controllers;
 9
 10/// <summary>
 11/// API endpoints for World management.
 12/// </summary>
 13[ApiController]
 14[Route("worlds")]
 15[Authorize]
 16public class WorldsController : ControllerBase
 17{
 18    private readonly IWorldService _worldService;
 19    private readonly IWorldMembershipService _membershipService;
 20    private readonly IWorldInvitationService _invitationService;
 21    private readonly IWorldPublicSharingService _publicSharingService;
 22    private readonly IExportService _exportService;
 23    private readonly IWorldLinkSuggestionService _worldLinkSuggestionService;
 24    private readonly ICurrentUserService _currentUserService;
 25    private readonly ILogger<WorldsController> _logger;
 26
 127    public WorldsController(
 128        IWorldService worldService,
 129        IWorldMembershipService membershipService,
 130        IWorldInvitationService invitationService,
 131        IWorldPublicSharingService publicSharingService,
 132        IExportService exportService,
 133        IWorldLinkSuggestionService worldLinkSuggestionService,
 134        ICurrentUserService currentUserService,
 135        ILogger<WorldsController> logger)
 36    {
 137        _worldService = worldService;
 138        _membershipService = membershipService;
 139        _invitationService = invitationService;
 140        _publicSharingService = publicSharingService;
 141        _exportService = exportService;
 142        _worldLinkSuggestionService = worldLinkSuggestionService;
 143        _currentUserService = currentUserService;
 144        _logger = logger;
 145    }
 46
 47    /// <summary>
 48    /// GET /api/worlds - Get all worlds the user has access to.
 49    /// </summary>
 50    [HttpGet]
 51    public async Task<ActionResult<IEnumerable<WorldDto>>> GetWorlds()
 52    {
 53        var user = await _currentUserService.GetRequiredUserAsync();
 54        _logger.LogTraceSanitized("Getting worlds for user {UserId}", user.Id);
 55
 56        var worlds = await _worldService.GetUserWorldsAsync(user.Id);
 57        return Ok(worlds);
 58    }
 59
 60    /// <summary>
 61    /// GET /api/worlds/{id} - Get a specific world with its campaigns.
 62    /// </summary>
 63    [HttpGet("{id:guid}")]
 64    public async Task<ActionResult<WorldDto>> GetWorld(Guid id)
 65    {
 66        var user = await _currentUserService.GetRequiredUserAsync();
 67        _logger.LogTraceSanitized("Getting world {WorldId} for user {UserId}", id, user.Id);
 68
 69        var world = await _worldService.GetWorldAsync(id, user.Id);
 70
 71        if (world == null)
 72        {
 73            return NotFound(new { error = "World not found or access denied" });
 74        }
 75
 76        return Ok(world);
 77    }
 78
 79    /// <summary>
 80    /// POST /api/worlds - Create a new world.
 81    /// </summary>
 82    [HttpPost]
 83    public async Task<ActionResult<WorldDto>> CreateWorld([FromBody] WorldCreateDto dto)
 84    {
 85        var user = await _currentUserService.GetRequiredUserAsync();
 86
 87        if (dto == null || string.IsNullOrWhiteSpace(dto.Name))
 88        {
 89            return BadRequest(new { error = "Name is required" });
 90        }
 91
 92        _logger.LogTraceSanitized("Creating world '{Name}' for user {UserId}", dto.Name, user.Id);
 93
 94        var world = await _worldService.CreateWorldAsync(dto, user.Id);
 95
 96        return CreatedAtAction(nameof(GetWorld), new { id = world.Id }, world);
 97    }
 98
 99    /// <summary>
 100    /// PUT /api/worlds/{id} - Update a world.
 101    /// </summary>
 102    [HttpPut("{id:guid}")]
 103    public async Task<ActionResult<WorldDto>> UpdateWorld(Guid id, [FromBody] WorldUpdateDto dto)
 104    {
 105        var user = await _currentUserService.GetRequiredUserAsync();
 106
 107        if (dto == null || string.IsNullOrWhiteSpace(dto.Name))
 108        {
 109            return BadRequest(new { error = "Name is required" });
 110        }
 111
 112        _logger.LogTraceSanitized("Updating world {WorldId} for user {UserId}", id, user.Id);
 113
 114        var world = await _worldService.UpdateWorldAsync(id, dto, user.Id);
 115
 116        if (world == null)
 117        {
 118            return NotFound(new { error = "World not found or access denied" });
 119        }
 120
 121        return Ok(world);
 122    }
 123
 124    /// <summary>
 125    /// POST /api/worlds/{id}/check-public-slug - Check if a public slug is available.
 126    /// </summary>
 127    [HttpPost("{id:guid}/check-public-slug")]
 128    public async Task<ActionResult<PublicSlugCheckResultDto>> CheckPublicSlug(Guid id, [FromBody] PublicSlugCheckDto dto
 129    {
 130        var user = await _currentUserService.GetRequiredUserAsync();
 131
 132        // Verify user owns this world
 133        var world = await _worldService.GetWorldAsync(id, user.Id);
 134        if (world == null || world.OwnerId != user.Id)
 135        {
 136            return StatusCode(403, new { error = "Only the world owner can check public slugs" });
 137        }
 138
 139        if (dto == null || string.IsNullOrWhiteSpace(dto.Slug))
 140        {
 141            return BadRequest(new { error = "Slug is required" });
 142        }
 143
 144        _logger.LogTraceSanitized("Checking public slug '{Slug}' for world {WorldId}", dto.Slug, id);
 145
 146        var result = await _publicSharingService.CheckPublicSlugAsync(dto.Slug, id);
 147        return Ok(result);
 148    }
 149
 150    // ===== Member Management =====
 151
 152    /// <summary>
 153    /// GET /api/worlds/{id}/members - Get all members of a world.
 154    /// </summary>
 155    [HttpGet("{id:guid}/members")]
 156    public async Task<ActionResult<IEnumerable<WorldMemberDto>>> GetWorldMembers(Guid id)
 157    {
 158        var user = await _currentUserService.GetRequiredUserAsync();
 159        _logger.LogTraceSanitized("Getting members for world {WorldId}", id);
 160
 161        var members = await _membershipService.GetMembersAsync(id, user.Id);
 162        return Ok(members);
 163    }
 164
 165    /// <summary>
 166    /// PUT /api/worlds/{worldId}/members/{memberId} - Update a member's role.
 167    /// </summary>
 168    [HttpPut("{worldId:guid}/members/{memberId:guid}")]
 169    public async Task<ActionResult<WorldMemberDto>> UpdateWorldMember(
 170        Guid worldId,
 171        Guid memberId,
 172        [FromBody] WorldMemberUpdateDto dto)
 173    {
 174        var user = await _currentUserService.GetRequiredUserAsync();
 175
 176        if (dto == null)
 177        {
 178            return BadRequest(new { error = "Invalid request body" });
 179        }
 180
 181        _logger.LogTraceSanitized("Updating member {MemberId} in world {WorldId}", memberId, worldId);
 182
 183        var member = await _membershipService.UpdateMemberRoleAsync(worldId, memberId, dto, user.Id);
 184
 185        if (member == null)
 186        {
 187            return NotFound(new { error = "Member not found, access denied, or cannot demote last GM" });
 188        }
 189
 190        return Ok(member);
 191    }
 192
 193    /// <summary>
 194    /// DELETE /api/worlds/{worldId}/members/{memberId} - Remove a member from a world.
 195    /// </summary>
 196    [HttpDelete("{worldId:guid}/members/{memberId:guid}")]
 197    public async Task<IActionResult> RemoveWorldMember(Guid worldId, Guid memberId)
 198    {
 199        var user = await _currentUserService.GetRequiredUserAsync();
 200        _logger.LogTraceSanitized("Removing member {MemberId} from world {WorldId}", memberId, worldId);
 201
 202        var success = await _membershipService.RemoveMemberAsync(worldId, memberId, user.Id);
 203
 204        if (!success)
 205        {
 206            return NotFound(new { error = "Member not found, access denied, or cannot remove last GM" });
 207        }
 208
 209        return NoContent();
 210    }
 211
 212    // ===== Invitation Management =====
 213
 214    /// <summary>
 215    /// GET /api/worlds/{id}/invitations - Get all invitations for a world.
 216    /// </summary>
 217    [HttpGet("{id:guid}/invitations")]
 218    public async Task<ActionResult<IEnumerable<WorldInvitationDto>>> GetWorldInvitations(Guid id)
 219    {
 220        var user = await _currentUserService.GetRequiredUserAsync();
 221        _logger.LogTraceSanitized("Getting invitations for world {WorldId}", id);
 222
 223        var invitations = await _invitationService.GetInvitationsAsync(id, user.Id);
 224        return Ok(invitations);
 225    }
 226
 227    /// <summary>
 228    /// POST /api/worlds/{id}/invitations - Create a new invitation.
 229    /// </summary>
 230    [HttpPost("{id:guid}/invitations")]
 231    public async Task<ActionResult<WorldInvitationDto>> CreateWorldInvitation(
 232        Guid id,
 233        [FromBody] WorldInvitationCreateDto? dto)
 234    {
 235        var user = await _currentUserService.GetRequiredUserAsync();
 236
 237        dto ??= new WorldInvitationCreateDto(); // Use defaults if body is empty
 238
 239        _logger.LogTraceSanitized("Creating invitation for world {WorldId}", id);
 240
 241        var invitation = await _invitationService.CreateInvitationAsync(id, dto, user.Id);
 242
 243        if (invitation == null)
 244        {
 245            return StatusCode(403, new { error = "Access denied or failed to create invitation" });
 246        }
 247
 248        return CreatedAtAction(nameof(GetWorldInvitations), new { id = id }, invitation);
 249    }
 250
 251    /// <summary>
 252    /// DELETE /api/worlds/{worldId}/invitations/{invitationId} - Revoke an invitation.
 253    /// </summary>
 254    [HttpDelete("{worldId:guid}/invitations/{invitationId:guid}")]
 255    public async Task<IActionResult> RevokeWorldInvitation(Guid worldId, Guid invitationId)
 256    {
 257        var user = await _currentUserService.GetRequiredUserAsync();
 258        _logger.LogTraceSanitized("Revoking invitation {InvitationId} for world {WorldId}", invitationId, worldId);
 259
 260        var success = await _invitationService.RevokeInvitationAsync(worldId, invitationId, user.Id);
 261
 262        if (!success)
 263        {
 264            return NotFound(new { error = "Invitation not found or access denied" });
 265        }
 266
 267        return NoContent();
 268    }
 269
 270    /// <summary>
 271    /// POST /api/worlds/join - Join a world using an invitation code.
 272    /// </summary>
 273    [HttpPost("join")]
 274    public async Task<ActionResult<WorldJoinResultDto>> JoinWorld([FromBody] WorldJoinDto dto)
 275    {
 276        var user = await _currentUserService.GetRequiredUserAsync();
 277
 278        if (dto == null || string.IsNullOrWhiteSpace(dto.Code))
 279        {
 280            return BadRequest(new { error = "Invitation code is required" });
 281        }
 282
 283        _logger.LogTraceSanitized("User {UserId} attempting to join world with code {Code}", user.Id, dto.Code);
 284
 285        var result = await _invitationService.JoinWorldAsync(dto.Code, user.Id);
 286
 287        if (result.Success)
 288        {
 289            return Ok(result);
 290        }
 291
 292        return BadRequest(result);
 293    }
 294
 295    // ===== Export =====
 296
 297    /// <summary>
 298    /// GET /api/worlds/{id}/export - Export world to a markdown zip archive.
 299    /// </summary>
 300    [HttpGet("{id:guid}/export")]
 301    public async Task<IActionResult> ExportWorld(Guid id)
 302    {
 303        var user = await _currentUserService.GetRequiredUserAsync();
 304        _logger.LogTraceSanitized("Exporting world {WorldId} for user {UserId}", id, user.Id);
 305
 306        var zipData = await _exportService.ExportWorldToMarkdownAsync(id, user.Id);
 307
 308        if (zipData == null)
 309        {
 310            return NotFound(new { error = "World not found or access denied" });
 311        }
 312
 313        // Get world name for filename
 314        var world = await _worldService.GetWorldAsync(id, user.Id);
 315        var worldName = world?.Name ?? "world";
 316        var safeWorldName = string.Join("_", worldName.Split(Path.GetInvalidFileNameChars()));
 317        if (safeWorldName.Length > 50)
 318            safeWorldName = safeWorldName[..50];
 319        var fileName = $"{safeWorldName}_export_{DateTime.UtcNow:yyyyMMdd_HHmmss}.zip";
 320
 321        return File(zipData, "application/zip", fileName);
 322    }
 323
 324    // ===== Link Suggestions =====
 325
 326    /// <summary>
 327    /// GET /worlds/{id}/link-suggestions - Get link suggestions for autocomplete based on a search query.
 328    /// </summary>
 329    [HttpGet("{id:guid}/link-suggestions")]
 330    public async Task<ActionResult<LinkSuggestionsResponseDto>> GetLinkSuggestions(
 331        Guid id,
 332        [FromQuery] string query)
 333    {
 334        var user = await _currentUserService.GetRequiredUserAsync();
 335
 336        if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
 337        {
 338            return Ok(new LinkSuggestionsResponseDto());
 339        }
 340
 341        _logger.LogTraceSanitized("Getting link suggestions for query '{Query}' in world {WorldId}", query, id);
 342        var result = await _worldLinkSuggestionService.GetSuggestionsAsync(id, query, user.Id);
 343        return result.Status switch
 344        {
 345            ServiceStatus.Forbidden => Forbid(),
 346            _ => Ok(new LinkSuggestionsResponseDto { Suggestions = result.Value ?? new List<LinkSuggestionDto>() })
 347        };
 348    }
 349
 350}