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