< 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
0%
Covered lines: 0
Uncovered lines: 171
Coverable lines: 171
Total lines: 412
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 54
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
GetWorlds()100%210%
GetWorld()0%620%
CreateWorld()0%2040%
UpdateWorld()0%4260%
CheckPublicSlug()0%7280%
GetWorldMembers()100%210%
UpdateWorldMember()0%2040%
RemoveWorldMember()0%620%
GetWorldInvitations()100%210%
CreateWorldInvitation()0%2040%
RevokeWorldInvitation()0%620%
JoinWorld()0%4260%
ExportWorld()0%7280%
GetLinkSuggestions()0%7280%

File(s)

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

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Api.Infrastructure;
 3using Chronicis.Api.Services;
 4using Chronicis.Shared.DTOs;
 5using Chronicis.Shared.Extensions;
 6using Microsoft.AspNetCore.Authorization;
 7using Microsoft.AspNetCore.Mvc;
 8using Microsoft.EntityFrameworkCore;
 9
 10namespace Chronicis.Api.Controllers;
 11
 12/// <summary>
 13/// API endpoints for World management.
 14/// </summary>
 15[ApiController]
 16[Route("worlds")]
 17[Authorize]
 18public class WorldsController : ControllerBase
 19{
 20    private readonly IWorldService _worldService;
 21    private readonly IWorldMembershipService _membershipService;
 22    private readonly IWorldInvitationService _invitationService;
 23    private readonly IWorldPublicSharingService _publicSharingService;
 24    private readonly IExportService _exportService;
 25    private readonly IArticleHierarchyService _hierarchyService;
 26    private readonly ChronicisDbContext _context;
 27    private readonly ICurrentUserService _currentUserService;
 28    private readonly ILogger<WorldsController> _logger;
 29
 030    public WorldsController(
 031        IWorldService worldService,
 032        IWorldMembershipService membershipService,
 033        IWorldInvitationService invitationService,
 034        IWorldPublicSharingService publicSharingService,
 035        IExportService exportService,
 036        IArticleHierarchyService hierarchyService,
 037        ChronicisDbContext context,
 038        ICurrentUserService currentUserService,
 039        ILogger<WorldsController> logger)
 40    {
 041        _worldService = worldService;
 042        _membershipService = membershipService;
 043        _invitationService = invitationService;
 044        _publicSharingService = publicSharingService;
 045        _exportService = exportService;
 046        _hierarchyService = hierarchyService;
 047        _context = context;
 048        _currentUserService = currentUserService;
 049        _logger = logger;
 050    }
 51
 52    /// <summary>
 53    /// GET /api/worlds - Get all worlds the user has access to.
 54    /// </summary>
 55    [HttpGet]
 56    public async Task<ActionResult<IEnumerable<WorldDto>>> GetWorlds()
 57    {
 058        var user = await _currentUserService.GetRequiredUserAsync();
 059        _logger.LogDebug("Getting worlds for user {UserId}", user.Id);
 60
 061        var worlds = await _worldService.GetUserWorldsAsync(user.Id);
 062        return Ok(worlds);
 063    }
 64
 65    /// <summary>
 66    /// GET /api/worlds/{id} - Get a specific world with its campaigns.
 67    /// </summary>
 68    [HttpGet("{id:guid}")]
 69    public async Task<ActionResult<WorldDto>> GetWorld(Guid id)
 70    {
 071        var user = await _currentUserService.GetRequiredUserAsync();
 072        _logger.LogDebug("Getting world {WorldId} for user {UserId}", id, user.Id);
 73
 074        var world = await _worldService.GetWorldAsync(id, user.Id);
 75
 076        if (world == null)
 77        {
 078            return NotFound(new { error = "World not found or access denied" });
 79        }
 80
 081        return Ok(world);
 082    }
 83
 84    /// <summary>
 85    /// POST /api/worlds - Create a new world.
 86    /// </summary>
 87    [HttpPost]
 88    public async Task<ActionResult<WorldDto>> CreateWorld([FromBody] WorldCreateDto dto)
 89    {
 090        var user = await _currentUserService.GetRequiredUserAsync();
 91
 092        if (dto == null || string.IsNullOrWhiteSpace(dto.Name))
 93        {
 094            return BadRequest(new { error = "Name is required" });
 95        }
 96
 097        _logger.LogDebugSanitized("Creating world '{Name}' for user {UserId}", dto.Name, user.Id);
 98
 099        var world = await _worldService.CreateWorldAsync(dto, user.Id);
 100
 0101        return CreatedAtAction(nameof(GetWorld), new { id = world.Id }, world);
 0102    }
 103
 104    /// <summary>
 105    /// PUT /api/worlds/{id} - Update a world.
 106    /// </summary>
 107    [HttpPut("{id:guid}")]
 108    public async Task<ActionResult<WorldDto>> UpdateWorld(Guid id, [FromBody] WorldUpdateDto dto)
 109    {
 0110        var user = await _currentUserService.GetRequiredUserAsync();
 111
 0112        if (dto == null || string.IsNullOrWhiteSpace(dto.Name))
 113        {
 0114            return BadRequest(new { error = "Name is required" });
 115        }
 116
 0117        _logger.LogDebug("Updating world {WorldId} for user {UserId}", id, user.Id);
 118
 0119        var world = await _worldService.UpdateWorldAsync(id, dto, user.Id);
 120
 0121        if (world == null)
 122        {
 0123            return NotFound(new { error = "World not found or access denied" });
 124        }
 125
 0126        return Ok(world);
 0127    }
 128
 129    /// <summary>
 130    /// POST /api/worlds/{id}/check-public-slug - Check if a public slug is available.
 131    /// </summary>
 132    [HttpPost("{id:guid}/check-public-slug")]
 133    public async Task<ActionResult<PublicSlugCheckResultDto>> CheckPublicSlug(Guid id, [FromBody] PublicSlugCheckDto dto
 134    {
 0135        var user = await _currentUserService.GetRequiredUserAsync();
 136
 137        // Verify user owns this world
 0138        var world = await _worldService.GetWorldAsync(id, user.Id);
 0139        if (world == null || world.OwnerId != user.Id)
 140        {
 0141            return StatusCode(403, new { error = "Only the world owner can check public slugs" });
 142        }
 143
 0144        if (dto == null || string.IsNullOrWhiteSpace(dto.Slug))
 145        {
 0146            return BadRequest(new { error = "Slug is required" });
 147        }
 148
 0149        _logger.LogDebugSanitized("Checking public slug '{Slug}' for world {WorldId}", dto.Slug, id);
 150
 0151        var result = await _publicSharingService.CheckPublicSlugAsync(dto.Slug, id);
 0152        return Ok(result);
 0153    }
 154
 155    // ===== Member Management =====
 156
 157    /// <summary>
 158    /// GET /api/worlds/{id}/members - Get all members of a world.
 159    /// </summary>
 160    [HttpGet("{id:guid}/members")]
 161    public async Task<ActionResult<IEnumerable<WorldMemberDto>>> GetWorldMembers(Guid id)
 162    {
 0163        var user = await _currentUserService.GetRequiredUserAsync();
 0164        _logger.LogDebug("Getting members for world {WorldId}", id);
 165
 0166        var members = await _membershipService.GetMembersAsync(id, user.Id);
 0167        return Ok(members);
 0168    }
 169
 170    /// <summary>
 171    /// PUT /api/worlds/{worldId}/members/{memberId} - Update a member's role.
 172    /// </summary>
 173    [HttpPut("{worldId:guid}/members/{memberId:guid}")]
 174    public async Task<ActionResult<WorldMemberDto>> UpdateWorldMember(
 175        Guid worldId,
 176        Guid memberId,
 177        [FromBody] WorldMemberUpdateDto dto)
 178    {
 0179        var user = await _currentUserService.GetRequiredUserAsync();
 180
 0181        if (dto == null)
 182        {
 0183            return BadRequest(new { error = "Invalid request body" });
 184        }
 185
 0186        _logger.LogDebug("Updating member {MemberId} in world {WorldId}", memberId, worldId);
 187
 0188        var member = await _membershipService.UpdateMemberRoleAsync(worldId, memberId, dto, user.Id);
 189
 0190        if (member == null)
 191        {
 0192            return NotFound(new { error = "Member not found, access denied, or cannot demote last GM" });
 193        }
 194
 0195        return Ok(member);
 0196    }
 197
 198    /// <summary>
 199    /// DELETE /api/worlds/{worldId}/members/{memberId} - Remove a member from a world.
 200    /// </summary>
 201    [HttpDelete("{worldId:guid}/members/{memberId:guid}")]
 202    public async Task<IActionResult> RemoveWorldMember(Guid worldId, Guid memberId)
 203    {
 0204        var user = await _currentUserService.GetRequiredUserAsync();
 0205        _logger.LogDebug("Removing member {MemberId} from world {WorldId}", memberId, worldId);
 206
 0207        var success = await _membershipService.RemoveMemberAsync(worldId, memberId, user.Id);
 208
 0209        if (!success)
 210        {
 0211            return NotFound(new { error = "Member not found, access denied, or cannot remove last GM" });
 212        }
 213
 0214        return NoContent();
 0215    }
 216
 217    // ===== Invitation Management =====
 218
 219    /// <summary>
 220    /// GET /api/worlds/{id}/invitations - Get all invitations for a world.
 221    /// </summary>
 222    [HttpGet("{id:guid}/invitations")]
 223    public async Task<ActionResult<IEnumerable<WorldInvitationDto>>> GetWorldInvitations(Guid id)
 224    {
 0225        var user = await _currentUserService.GetRequiredUserAsync();
 0226        _logger.LogDebug("Getting invitations for world {WorldId}", id);
 227
 0228        var invitations = await _invitationService.GetInvitationsAsync(id, user.Id);
 0229        return Ok(invitations);
 0230    }
 231
 232    /// <summary>
 233    /// POST /api/worlds/{id}/invitations - Create a new invitation.
 234    /// </summary>
 235    [HttpPost("{id:guid}/invitations")]
 236    public async Task<ActionResult<WorldInvitationDto>> CreateWorldInvitation(
 237        Guid id,
 238        [FromBody] WorldInvitationCreateDto? dto)
 239    {
 0240        var user = await _currentUserService.GetRequiredUserAsync();
 241
 0242        dto ??= new WorldInvitationCreateDto(); // Use defaults if body is empty
 243
 0244        _logger.LogDebug("Creating invitation for world {WorldId}", id);
 245
 0246        var invitation = await _invitationService.CreateInvitationAsync(id, dto, user.Id);
 247
 0248        if (invitation == null)
 249        {
 0250            return StatusCode(403, new { error = "Access denied or failed to create invitation" });
 251        }
 252
 0253        return CreatedAtAction(nameof(GetWorldInvitations), new { id = id }, invitation);
 0254    }
 255
 256    /// <summary>
 257    /// DELETE /api/worlds/{worldId}/invitations/{invitationId} - Revoke an invitation.
 258    /// </summary>
 259    [HttpDelete("{worldId:guid}/invitations/{invitationId:guid}")]
 260    public async Task<IActionResult> RevokeWorldInvitation(Guid worldId, Guid invitationId)
 261    {
 0262        var user = await _currentUserService.GetRequiredUserAsync();
 0263        _logger.LogDebug("Revoking invitation {InvitationId} for world {WorldId}", invitationId, worldId);
 264
 0265        var success = await _invitationService.RevokeInvitationAsync(worldId, invitationId, user.Id);
 266
 0267        if (!success)
 268        {
 0269            return NotFound(new { error = "Invitation not found or access denied" });
 270        }
 271
 0272        return NoContent();
 0273    }
 274
 275    /// <summary>
 276    /// POST /api/worlds/join - Join a world using an invitation code.
 277    /// </summary>
 278    [HttpPost("join")]
 279    public async Task<ActionResult<WorldJoinResultDto>> JoinWorld([FromBody] WorldJoinDto dto)
 280    {
 0281        var user = await _currentUserService.GetRequiredUserAsync();
 282
 0283        if (dto == null || string.IsNullOrWhiteSpace(dto.Code))
 284        {
 0285            return BadRequest(new { error = "Invitation code is required" });
 286        }
 287
 0288        _logger.LogDebugSanitized("User {UserId} attempting to join world with code {Code}", user.Id, dto.Code);
 289
 0290        var result = await _invitationService.JoinWorldAsync(dto.Code, user.Id);
 291
 0292        if (result.Success)
 293        {
 0294            return Ok(result);
 295        }
 296
 0297        return BadRequest(result);
 0298    }
 299
 300    // ===== Export =====
 301
 302    /// <summary>
 303    /// GET /api/worlds/{id}/export - Export world to a markdown zip archive.
 304    /// </summary>
 305    [HttpGet("{id:guid}/export")]
 306    public async Task<IActionResult> ExportWorld(Guid id)
 307    {
 0308        var user = await _currentUserService.GetRequiredUserAsync();
 0309        _logger.LogDebug("Exporting world {WorldId} for user {UserId}", id, user.Id);
 310
 0311        var zipData = await _exportService.ExportWorldToMarkdownAsync(id, user.Id);
 312
 0313        if (zipData == null)
 314        {
 0315            return NotFound(new { error = "World not found or access denied" });
 316        }
 317
 318        // Get world name for filename
 0319        var world = await _worldService.GetWorldAsync(id, user.Id);
 0320        var worldName = world?.Name ?? "world";
 0321        var safeWorldName = string.Join("_", worldName.Split(Path.GetInvalidFileNameChars()));
 0322        if (safeWorldName.Length > 50)
 0323            safeWorldName = safeWorldName[..50];
 0324        var fileName = $"{safeWorldName}_export_{DateTime.UtcNow:yyyyMMdd_HHmmss}.zip";
 325
 0326        return File(zipData, "application/zip", fileName);
 0327    }
 328
 329    // ===== Link Suggestions =====
 330
 331    /// <summary>
 332    /// GET /worlds/{id}/link-suggestions - Get link suggestions for autocomplete based on a search query.
 333    /// </summary>
 334    [HttpGet("{id:guid}/link-suggestions")]
 335    public async Task<ActionResult<LinkSuggestionsResponseDto>> GetLinkSuggestions(
 336        Guid id,
 337        [FromQuery] string query)
 338    {
 0339        var user = await _currentUserService.GetRequiredUserAsync();
 340
 0341        if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
 342        {
 0343            return Ok(new LinkSuggestionsResponseDto());
 344        }
 345
 0346        _logger.LogDebugSanitized("Getting link suggestions for query '{Query}' in world {WorldId}", query, id);
 347
 348        // Verify user has access to the world
 0349        var hasAccess = await _context.WorldMembers
 0350            .AnyAsync(wm => wm.WorldId == id && wm.UserId == user.Id);
 351
 0352        if (!hasAccess)
 353        {
 0354            return Forbid();
 355        }
 356
 0357        var normalizedQuery = query.ToLowerInvariant();
 358
 359        // Search articles by title match
 0360        var titleMatches = await _context.Articles
 0361            .Where(a => a.WorldId == id)
 0362            .Where(a => a.Title != null && a.Title.ToLower().Contains(normalizedQuery))
 0363            .OrderBy(a => a.Title)
 0364            .Take(20)
 0365            .Select(a => new LinkSuggestionDto
 0366            {
 0367                ArticleId = a.Id,
 0368                Title = a.Title ?? "Untitled",
 0369                Slug = a.Slug,
 0370                ArticleType = a.Type,
 0371                DisplayPath = "",
 0372                MatchedAlias = null // Title match, no alias
 0373            })
 0374            .ToListAsync();
 375
 376        // Search articles by alias match (excluding those already found by title)
 0377        var titleMatchIds = titleMatches.Select(t => t.ArticleId).ToHashSet();
 378
 0379        var aliasMatches = await _context.ArticleAliases
 0380            .Include(aa => aa.Article)
 0381            .Where(aa => aa.Article.WorldId == id)
 0382            .Where(aa => aa.AliasText.ToLower().Contains(normalizedQuery))
 0383            .Where(aa => !titleMatchIds.Contains(aa.ArticleId))
 0384            .OrderBy(aa => aa.AliasText)
 0385            .Take(20)
 0386            .Select(aa => new LinkSuggestionDto
 0387            {
 0388                ArticleId = aa.ArticleId,
 0389                Title = aa.Article.Title ?? "Untitled",
 0390                Slug = aa.Article.Slug,
 0391                ArticleType = aa.Article.Type,
 0392                DisplayPath = "",
 0393                MatchedAlias = aa.AliasText // This matched via alias
 0394            })
 0395            .ToListAsync();
 396
 397        // Combine results: title matches first, then alias matches
 0398        var suggestions = titleMatches
 0399            .Concat(aliasMatches)
 0400            .Take(20)
 0401            .ToList();
 402
 403        // Build display paths using centralised hierarchy service
 0404        foreach (var suggestion in suggestions)
 405        {
 0406            suggestion.DisplayPath = await _hierarchyService.BuildDisplayPathAsync(suggestion.ArticleId);
 407        }
 408
 0409        return Ok(new LinkSuggestionsResponseDto { Suggestions = suggestions });
 0410    }
 411
 412}