< Summary

Information
Class: Chronicis.Api.Services.WorldService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/WorldService.cs
Line coverage
100%
Covered lines: 61
Uncovered lines: 0
Coverable lines: 61
Total lines: 402
Line coverage: 100%
Branch coverage
100%
Covered branches: 24
Total branches: 24
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
MapToDto(...)100%88100%
MapToDetailDto(...)100%1616100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/WorldService.cs

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Api.Models;
 3using Chronicis.Shared.DTOs;
 4using Chronicis.Shared.Enums;
 5using Chronicis.Shared.Models;
 6using Chronicis.Shared.Utilities;
 7using Microsoft.EntityFrameworkCore;
 8
 9namespace Chronicis.Api.Services;
 10
 11/// <summary>
 12/// Service for core world management (CRUD, lookup, creation)
 13/// </summary>
 14public sealed class WorldService : IWorldService
 15{
 16    private readonly ChronicisDbContext _context;
 17    private readonly IWorldMembershipService _membershipService;
 18    private readonly IReservedSlugProvider _reservedSlugProvider;
 19    private readonly ILogger<WorldService> _logger;
 20
 21    public WorldService(
 22        ChronicisDbContext context,
 23        IWorldMembershipService membershipService,
 24        IReservedSlugProvider reservedSlugProvider,
 25        ILogger<WorldService> logger)
 26    {
 3427        _context = context;
 3428        _membershipService = membershipService;
 3429        _reservedSlugProvider = reservedSlugProvider;
 3430        _logger = logger;
 3431    }
 32
 33    public async Task<List<WorldDto>> GetUserWorldsAsync(Guid userId)
 34    {
 35        var worlds = await _context.Worlds
 36            .Where(w => w.Members.Any(m => m.UserId == userId))
 37            .Include(w => w.Owner)
 38            .Include(w => w.Campaigns)
 39            .Include(w => w.Members)
 40            .ToListAsync();
 41
 42        return worlds.Select(MapToDto).ToList();
 43    }
 44
 45    public async Task<WorldDetailDto?> GetWorldAsync(Guid worldId, Guid userId)
 46    {
 47        var world = await _context.Worlds
 48            .Include(w => w.Owner)
 49            .Include(w => w.Campaigns)
 50                .ThenInclude(c => c.Owner)
 51            .Include(w => w.Members)
 52                .ThenInclude(m => m.User)
 53            .FirstOrDefaultAsync(w => w.Id == worldId);
 54
 55        if (world == null)
 56            return null;
 57
 58        // Check access via membership service
 59        if (!await _membershipService.UserHasAccessAsync(worldId, userId))
 60            return null;
 61
 62        var canViewPrivateNotes = world.OwnerId == userId
 63            || world.Members.Any(m => m.UserId == userId && m.Role == WorldRole.GM);
 64
 65        var dto = MapToDetailDto(world);
 66        if (!canViewPrivateNotes)
 67        {
 68            dto.PrivateNotes = null;
 69        }
 70
 71        return dto;
 72    }
 73
 74    public async Task<WorldDto> CreateWorldAsync(WorldCreateDto dto, Guid userId)
 75    {
 76        var user = await _context.Users.FindAsync(userId);
 77        if (user == null)
 78            throw new InvalidOperationException("User not found");
 79
 80        _logger.LogTraceSanitized("Creating world '{Name}' for user {UserId}", dto.Name, userId);
 81
 82        var now = DateTime.UtcNow;
 83
 84        // Generate globally unique slug (avoids reserved slugs automatically)
 85        var slug = await GenerateUniqueWorldSlugAsync(dto.Name);
 86
 87        // Create the World entity
 88        var world = new World
 89        {
 90            Id = Guid.NewGuid(),
 91            Name = dto.Name,
 92            Slug = slug,
 93            Description = dto.Description,
 94            OwnerId = userId,
 95            CreatedAt = now,
 96            IsPublic = false
 97        };
 98        _context.Worlds.Add(world);
 99
 100        // Create default Wiki articles
 101        var wikiArticles = new[]
 102        {
 103            new Article
 104            {
 105                Id = Guid.NewGuid(),
 106                Title = "Bestiary",
 107                Slug = "bestiary",
 108                Body = "# Bestiary\n\nA collection of creatures and monsters encountered in your adventures.",
 109                Type = ArticleType.WikiArticle,
 110                Visibility = ArticleVisibility.Public,
 111                WorldId = world.Id,
 112                CreatedBy = userId,
 113                CreatedAt = now,
 114                EffectiveDate = now,
 115                IconEmoji = "🐉"
 116            },
 117            new Article
 118            {
 119                Id = Guid.NewGuid(),
 120                Title = "Characters",
 121                Slug = "characters",
 122                Body = "# Characters\n\nNPCs and notable figures in your world.",
 123                Type = ArticleType.WikiArticle,
 124                Visibility = ArticleVisibility.Public,
 125                WorldId = world.Id,
 126                CreatedBy = userId,
 127                CreatedAt = now,
 128                EffectiveDate = now,
 129                IconEmoji = "👤"
 130            },
 131            new Article
 132            {
 133                Id = Guid.NewGuid(),
 134                Title = "Factions",
 135                Slug = "factions",
 136                Body = "# Factions\n\nOrganizations, guilds, and groups that shape your world.",
 137                Type = ArticleType.WikiArticle,
 138                Visibility = ArticleVisibility.Public,
 139                WorldId = world.Id,
 140                CreatedBy = userId,
 141                CreatedAt = now,
 142                EffectiveDate = now,
 143                IconEmoji = "⚔️"
 144            },
 145            new Article
 146            {
 147                Id = Guid.NewGuid(),
 148                Title = "Locations",
 149                Slug = "locations",
 150                Body = "# Locations\n\nPlaces of interest, cities, dungeons, and landmarks.",
 151                Type = ArticleType.WikiArticle,
 152                Visibility = ArticleVisibility.Public,
 153                WorldId = world.Id,
 154                CreatedBy = userId,
 155                CreatedAt = now,
 156                EffectiveDate = now,
 157                IconEmoji = "🗺️"
 158            }
 159        };
 160        _context.Articles.AddRange(wikiArticles);
 161
 162        // Create default Player Character
 163        var newCharacter = new Article
 164        {
 165            Id = Guid.NewGuid(),
 166            Title = "New Character",
 167            Slug = "new-character",
 168            Body = "# New Character\n\nDescribe your character here. Add their backstory, personality, and goals.",
 169            Type = ArticleType.Character,
 170            Visibility = ArticleVisibility.Public,
 171            WorldId = world.Id,
 172            CreatedBy = userId,
 173            PlayerId = userId,
 174            CreatedAt = now,
 175            EffectiveDate = now,
 176            IconEmoji = "🧙"
 177        };
 178        _context.Articles.Add(newCharacter);
 179
 180        // Create default Campaign
 181        var campaign = new Campaign
 182        {
 183            Id = Guid.NewGuid(),
 184            Name = "Campaign 1",
 185            Slug = "campaign-1",
 186            Description = "Your first campaign adventure begins here.",
 187            WorldId = world.Id,
 188            OwnerId = userId,
 189            CreatedAt = now
 190        };
 191        _context.Campaigns.Add(campaign);
 192
 193        // Create default Arc under the campaign
 194        var arc = new Arc
 195        {
 196            Id = Guid.NewGuid(),
 197            Name = "Arc 1",
 198            Slug = "arc-1",
 199            Description = "The first chapter of your adventure.",
 200            CampaignId = campaign.Id,
 201            SortOrder = 1,
 202            CreatedBy = userId,
 203            CreatedAt = now
 204        };
 205        _context.Arcs.Add(arc);
 206
 207        // Add the creator as a GM member of their new world
 208        var ownerMembership = new WorldMember
 209        {
 210            Id = Guid.NewGuid(),
 211            WorldId = world.Id,
 212            UserId = userId,
 213            Role = WorldRole.GM,
 214            JoinedAt = now,
 215            InvitedBy = null
 216        };
 217        _context.WorldMembers.Add(ownerMembership);
 218
 219        await _context.SaveChangesAsync();
 220
 221        _logger.LogTraceSanitized("Created world {WorldId} with default content for user {UserId}", world.Id, userId);
 222
 223        world.Owner = user;
 224        return MapToDto(world);
 225    }
 226
 227    public async Task<WorldDto?> UpdateWorldAsync(Guid worldId, WorldUpdateDto dto, Guid userId)
 228    {
 229        var world = await _context.Worlds
 230            .Include(w => w.Owner)
 231            .Include(w => w.Campaigns)
 232            .FirstOrDefaultAsync(w => w.Id == worldId);
 233
 234        if (world == null)
 235            return null;
 236
 237        var isOwner = world.OwnerId == userId;
 238        var isGM = await _context.WorldMembers.AnyAsync(m =>
 239            m.WorldId == worldId &&
 240            m.UserId == userId &&
 241            m.Role == WorldRole.GM);
 242
 243        // Only the world owner or a GM can update
 244        if (!isOwner && !isGM)
 245            return null;
 246
 247        // If name changed, regenerate slug (globally unique, avoids reserved slugs)
 248        if (world.Name != dto.Name)
 249        {
 250            world.Slug = await GenerateUniqueWorldSlugAsync(dto.Name, world.Id);
 251        }
 252
 253        world.Name = dto.Name;
 254        world.Description = dto.Description;
 255        world.PrivateNotes = string.IsNullOrWhiteSpace(dto.PrivateNotes) ? null : dto.PrivateNotes;
 256
 257        // Handle public visibility changes if specified
 258        if (dto.IsPublic.HasValue)
 259        {
 260            world.IsPublic = dto.IsPublic.Value;
 261            _logger.LogTraceSanitized("World {WorldId} IsPublic set to {IsPublic}", worldId, dto.IsPublic.Value);
 262        }
 263
 264        await _context.SaveChangesAsync();
 265
 266        _logger.LogTraceSanitized("Updated world {WorldId}", worldId);
 267
 268        return MapToDto(world);
 269    }
 270
 271    public async Task<WorldDto?> GetWorldBySlugAsync(string slug, Guid userId)
 272    {
 273        var world = await _context.Worlds
 274            .AsNoTracking()
 275            .Include(w => w.Owner)
 276            .Include(w => w.Campaigns)
 277            .Include(w => w.Members)
 278            .FirstOrDefaultAsync(w => w.Slug == slug && w.Members.Any(m => m.UserId == userId));
 279
 280        if (world == null)
 281            return null;
 282
 283        return MapToDto(world);
 284    }
 285
 286    public async Task<(Guid Id, bool IsPublic, string Name)?> GetIdBySlugAsync(string slug)
 287    {
 288        var row = await _context.Worlds.AsNoTracking()
 289            .Where(w => w.Slug == slug)
 290            .Select(w => new { w.Id, w.IsPublic, w.Name })
 291            .FirstOrDefaultAsync();
 292
 293        return row == null ? null : (row.Id, row.IsPublic, row.Name);
 294    }
 295
 296    public async Task<ServiceResult<string>> UpdateSlugAsync(Guid worldId, string slug, Guid userId)
 297    {
 298        var world = await _context.Worlds.FirstOrDefaultAsync(w => w.Id == worldId);
 299        if (world == null)
 300            return ServiceResult<string>.NotFound("World not found");
 301
 302        if (world.OwnerId != userId)
 303            return ServiceResult<string>.Forbidden("Only the world owner may update the slug");
 304
 305        if (!SlugGenerator.IsValidSlug(slug))
 306            return ServiceResult<string>.ValidationError("SLUG_INVALID");
 307
 308        if (_reservedSlugProvider.IsReserved(slug))
 309            return ServiceResult<string>.ValidationError("SLUG_RESERVED");
 310
 311        var finalSlug = await GenerateUniqueWorldSlugAsync(slug, worldId);
 312        world.Slug = finalSlug;
 313        await _context.SaveChangesAsync();
 314
 315        return ServiceResult<string>.Success(finalSlug);
 316    }
 317
 318    /// <summary>
 319    /// Generate a globally unique slug for a world, avoiding reserved slugs.
 320    /// </summary>
 321    private async Task<string> GenerateUniqueWorldSlugAsync(string name, Guid? excludeWorldId = null)
 322    {
 323        var baseSlug = SlugGenerator.GenerateSlug(name);
 324
 325        var existingSlugsQuery = _context.Worlds.AsNoTracking();
 326
 327        if (excludeWorldId.HasValue)
 328        {
 329            existingSlugsQuery = existingSlugsQuery.Where(w => w.Id != excludeWorldId.Value);
 330        }
 331
 332        var existingSlugs = await existingSlugsQuery
 333            .Select(w => w.Slug)
 334            .ToHashSetAsync();
 335
 336        return SlugGenerator.GenerateUniqueSiblingSlug(baseSlug, existingSlugs, _reservedSlugProvider.All);
 337    }
 338
 339    internal static WorldDto MapToDto(World world)
 340    {
 16341        return new WorldDto
 16342        {
 16343            Id = world.Id,
 16344            Name = world.Name,
 16345            Slug = world.Slug,
 16346            Description = world.Description,
 16347            OwnerId = world.OwnerId,
 16348            OwnerName = world.Owner?.DisplayName ?? "Unknown",
 16349            CreatedAt = world.CreatedAt,
 16350            CampaignCount = world.Campaigns?.Count ?? 0,
 16351            MemberCount = world.Members?.Count ?? 0,
 16352            IsPublic = world.IsPublic,
 16353            IsTutorial = world.IsTutorial,
 16354        };
 355    }
 356
 357    internal static WorldDetailDto MapToDetailDto(World world)
 358    {
 3359        return new WorldDetailDto
 3360        {
 3361            Id = world.Id,
 3362            Name = world.Name,
 3363            Slug = world.Slug,
 3364            Description = world.Description,
 3365            OwnerId = world.OwnerId,
 3366            OwnerName = world.Owner?.DisplayName ?? "Unknown",
 3367            CreatedAt = world.CreatedAt,
 3368            CampaignCount = world.Campaigns?.Count ?? 0,
 3369            MemberCount = world.Members?.Count ?? 0,
 3370            IsPublic = world.IsPublic,
 3371            IsTutorial = world.IsTutorial,
 3372            PrivateNotes = world.PrivateNotes,
 3373            Campaigns = world.Campaigns?.Select(c => new CampaignDto
 3374            {
 3375                Id = c.Id,
 3376                WorldId = c.WorldId,
 3377                Name = c.Name,
 3378                Description = c.Description,
 3379                OwnerId = c.OwnerId,
 3380                OwnerName = c.Owner?.DisplayName ?? "Unknown",
 3381                CreatedAt = c.CreatedAt,
 3382                StartedAt = c.StartedAt,
 3383                EndedAt = c.EndedAt,
 3384                IsActive = c.IsActive,
 3385                ArcCount = c.Arcs?.Count ?? 0,
 3386                Slug = c.Slug,
 3387                WorldSlug = world.Slug
 3388            }).ToList() ?? new List<CampaignDto>(),
 3389            Members = world.Members?.Select(m => new WorldMemberDto
 3390            {
 3391                Id = m.Id,
 3392                UserId = m.UserId,
 3393                DisplayName = m.User?.DisplayName ?? "Unknown",
 3394                Email = m.User?.Email ?? "",
 3395                AvatarUrl = m.User?.AvatarUrl,
 3396                Role = m.Role,
 3397                JoinedAt = m.JoinedAt,
 3398                InvitedBy = m.InvitedBy
 3399            }).ToList() ?? new List<WorldMemberDto>()
 3400        };
 401    }
 402}