< 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: 59
Uncovered lines: 0
Coverable lines: 59
Total lines: 406
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.Shared.DTOs;
 3using Chronicis.Shared.Enums;
 4using Chronicis.Shared.Models;
 5using Chronicis.Shared.Utilities;
 6using Microsoft.EntityFrameworkCore;
 7
 8namespace Chronicis.Api.Services;
 9
 10/// <summary>
 11/// Service for core world management (CRUD, lookup, creation)
 12/// </summary>
 13public sealed class WorldService : IWorldService
 14{
 15    private readonly ChronicisDbContext _context;
 16    private readonly IWorldMembershipService _membershipService;
 17    private readonly IWorldPublicSharingService _publicSharingService;
 18    private readonly ILogger<WorldService> _logger;
 19
 20    public WorldService(
 21        ChronicisDbContext context,
 22        IWorldMembershipService membershipService,
 23        IWorldPublicSharingService publicSharingService,
 24        ILogger<WorldService> logger)
 25    {
 1926        _context = context;
 1927        _membershipService = membershipService;
 1928        _publicSharingService = publicSharingService;
 1929        _logger = logger;
 1930    }
 31
 32    public async Task<List<WorldDto>> GetUserWorldsAsync(Guid userId)
 33    {
 34        var worlds = await _context.Worlds
 35            .Where(w => w.Members.Any(m => m.UserId == userId))
 36            .Include(w => w.Owner)
 37            .Include(w => w.Campaigns)
 38            .Include(w => w.Members)
 39            .ToListAsync();
 40
 41        return worlds.Select(MapToDto).ToList();
 42    }
 43
 44    public async Task<WorldDetailDto?> GetWorldAsync(Guid worldId, Guid userId)
 45    {
 46        var world = await _context.Worlds
 47            .Include(w => w.Owner)
 48            .Include(w => w.Campaigns)
 49                .ThenInclude(c => c.Owner)
 50            .Include(w => w.Members)
 51                .ThenInclude(m => m.User)
 52            .FirstOrDefaultAsync(w => w.Id == worldId);
 53
 54        if (world == null)
 55            return null;
 56
 57        // Check access via membership service
 58        if (!await _membershipService.UserHasAccessAsync(worldId, userId))
 59            return null;
 60
 61        var canViewPrivateNotes = world.OwnerId == userId
 62            || world.Members.Any(m => m.UserId == userId && m.Role == WorldRole.GM);
 63
 64        var dto = MapToDetailDto(world);
 65        if (!canViewPrivateNotes)
 66        {
 67            dto.PrivateNotes = null;
 68        }
 69
 70        return dto;
 71    }
 72
 73    public async Task<WorldDto> CreateWorldAsync(WorldCreateDto dto, Guid userId)
 74    {
 75        var user = await _context.Users.FindAsync(userId);
 76        if (user == null)
 77            throw new InvalidOperationException("User not found");
 78
 79        _logger.LogTraceSanitized("Creating world '{Name}' for user {UserId}", dto.Name, userId);
 80
 81        var now = DateTime.UtcNow;
 82
 83        // Generate unique slug for this owner
 84        var slug = await GenerateUniqueWorldSlugAsync(dto.Name, userId);
 85
 86        // Create the World entity
 87        var world = new World
 88        {
 89            Id = Guid.NewGuid(),
 90            Name = dto.Name,
 91            Slug = slug,
 92            Description = dto.Description,
 93            OwnerId = userId,
 94            CreatedAt = now,
 95            IsPublic = false,
 96            PublicSlug = null
 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            Description = "Your first campaign adventure begins here.",
 186            WorldId = world.Id,
 187            OwnerId = userId,
 188            CreatedAt = now
 189        };
 190        _context.Campaigns.Add(campaign);
 191
 192        // Create default Arc under the campaign
 193        var arc = new Arc
 194        {
 195            Id = Guid.NewGuid(),
 196            Name = "Arc 1",
 197            Description = "The first chapter of your adventure.",
 198            CampaignId = campaign.Id,
 199            SortOrder = 1,
 200            CreatedBy = userId,
 201            CreatedAt = now
 202        };
 203        _context.Arcs.Add(arc);
 204
 205        // Add the creator as a GM member of their new world
 206        var ownerMembership = new WorldMember
 207        {
 208            Id = Guid.NewGuid(),
 209            WorldId = world.Id,
 210            UserId = userId,
 211            Role = WorldRole.GM,
 212            JoinedAt = now,
 213            InvitedBy = null
 214        };
 215        _context.WorldMembers.Add(ownerMembership);
 216
 217        await _context.SaveChangesAsync();
 218
 219        _logger.LogTraceSanitized("Created world {WorldId} with default content for user {UserId}", world.Id, userId);
 220
 221        world.Owner = user;
 222        return MapToDto(world);
 223    }
 224
 225    public async Task<WorldDto?> UpdateWorldAsync(Guid worldId, WorldUpdateDto dto, Guid userId)
 226    {
 227        var world = await _context.Worlds
 228            .Include(w => w.Owner)
 229            .Include(w => w.Campaigns)
 230            .FirstOrDefaultAsync(w => w.Id == worldId);
 231
 232        if (world == null)
 233            return null;
 234
 235        var isOwner = world.OwnerId == userId;
 236        var isGM = await _context.WorldMembers.AnyAsync(m =>
 237            m.WorldId == worldId &&
 238            m.UserId == userId &&
 239            m.Role == WorldRole.GM);
 240
 241        // Only the world owner or a GM can update
 242        if (!isOwner && !isGM)
 243            return null;
 244
 245        // If name changed, regenerate slug
 246        if (world.Name != dto.Name)
 247        {
 248            world.Slug = await GenerateUniqueWorldSlugAsync(dto.Name, userId, world.Id);
 249        }
 250
 251        world.Name = dto.Name;
 252        world.Description = dto.Description;
 253        world.PrivateNotes = string.IsNullOrWhiteSpace(dto.PrivateNotes) ? null : dto.PrivateNotes;
 254
 255        // Handle public visibility changes if specified
 256        if (dto.IsPublic.HasValue)
 257        {
 258            if (dto.IsPublic.Value)
 259            {
 260                // Making world public - require a valid public slug
 261                if (string.IsNullOrWhiteSpace(dto.PublicSlug))
 262                {
 263                    _logger.LogWarningSanitized("Attempted to make world {WorldId} public without a public slug", worldI
 264                    return null;
 265                }
 266
 267                var normalizedSlug = dto.PublicSlug.Trim().ToLowerInvariant();
 268
 269                // Validate slug format via public sharing service
 270                var validationError = _publicSharingService.ValidatePublicSlug(normalizedSlug);
 271                if (validationError != null)
 272                {
 273                    _logger.LogWarningSanitized("Invalid public slug '{Slug}' for world {WorldId}: {Error}",
 274                        normalizedSlug, worldId, validationError);
 275                    return null;
 276                }
 277
 278                // Check availability via public sharing service
 279                if (!await _publicSharingService.IsPublicSlugAvailableAsync(normalizedSlug, worldId))
 280                {
 281                    _logger.LogWarningSanitized("Public slug '{Slug}' is already taken", normalizedSlug);
 282                    return null;
 283                }
 284
 285                world.IsPublic = true;
 286                world.PublicSlug = normalizedSlug;
 287
 288                _logger.LogTraceSanitized("World {WorldId} is now public with slug '{PublicSlug}'", worldId, normalizedS
 289            }
 290            else
 291            {
 292                // Making world private - clear public slug
 293                world.IsPublic = false;
 294                world.PublicSlug = null;
 295
 296                _logger.LogTraceSanitized("World {WorldId} is now private", worldId);
 297            }
 298        }
 299
 300        await _context.SaveChangesAsync();
 301
 302        _logger.LogTraceSanitized("Updated world {WorldId}", worldId);
 303
 304        return MapToDto(world);
 305    }
 306
 307    public async Task<WorldDto?> GetWorldBySlugAsync(string slug, Guid userId)
 308    {
 309        var world = await _context.Worlds
 310            .AsNoTracking()
 311            .Include(w => w.Owner)
 312            .Include(w => w.Campaigns)
 313            .Include(w => w.Members)
 314            .FirstOrDefaultAsync(w => w.Slug == slug && w.Members.Any(m => m.UserId == userId));
 315
 316        if (world == null)
 317            return null;
 318
 319        return MapToDto(world);
 320    }
 321
 322    /// <summary>
 323    /// Generate a unique slug for a world within an owner's worlds.
 324    /// </summary>
 325    private async Task<string> GenerateUniqueWorldSlugAsync(string name, Guid ownerId, Guid? excludeWorldId = null)
 326    {
 327        var baseSlug = SlugGenerator.GenerateSlug(name);
 328
 329        var existingSlugsQuery = _context.Worlds
 330            .AsNoTracking()
 331            .Where(w => w.OwnerId == ownerId);
 332
 333        if (excludeWorldId.HasValue)
 334        {
 335            existingSlugsQuery = existingSlugsQuery.Where(w => w.Id != excludeWorldId.Value);
 336        }
 337
 338        var existingSlugs = await existingSlugsQuery
 339            .Select(w => w.Slug)
 340            .ToHashSetAsync();
 341
 342        return SlugGenerator.GenerateUniqueSlug(baseSlug, existingSlugs);
 343    }
 344
 345    internal static WorldDto MapToDto(World world)
 346    {
 12347        return new WorldDto
 12348        {
 12349            Id = world.Id,
 12350            Name = world.Name,
 12351            Slug = world.Slug,
 12352            Description = world.Description,
 12353            OwnerId = world.OwnerId,
 12354            OwnerName = world.Owner?.DisplayName ?? "Unknown",
 12355            CreatedAt = world.CreatedAt,
 12356            CampaignCount = world.Campaigns?.Count ?? 0,
 12357            MemberCount = world.Members?.Count ?? 0,
 12358            IsPublic = world.IsPublic,
 12359            IsTutorial = world.IsTutorial,
 12360            PublicSlug = world.PublicSlug
 12361        };
 362    }
 363
 364    internal static WorldDetailDto MapToDetailDto(World world)
 365    {
 2366        return new WorldDetailDto
 2367        {
 2368            Id = world.Id,
 2369            Name = world.Name,
 2370            Slug = world.Slug,
 2371            Description = world.Description,
 2372            OwnerId = world.OwnerId,
 2373            OwnerName = world.Owner?.DisplayName ?? "Unknown",
 2374            CreatedAt = world.CreatedAt,
 2375            CampaignCount = world.Campaigns?.Count ?? 0,
 2376            MemberCount = world.Members?.Count ?? 0,
 2377            IsPublic = world.IsPublic,
 2378            IsTutorial = world.IsTutorial,
 2379            PublicSlug = world.PublicSlug,
 2380            PrivateNotes = world.PrivateNotes,
 2381            Campaigns = world.Campaigns?.Select(c => new CampaignDto
 2382            {
 2383                Id = c.Id,
 2384                WorldId = c.WorldId,
 2385                Name = c.Name,
 2386                Description = c.Description,
 2387                OwnerId = c.OwnerId,
 2388                OwnerName = c.Owner?.DisplayName ?? "Unknown",
 2389                CreatedAt = c.CreatedAt,
 2390                StartedAt = c.StartedAt,
 2391                EndedAt = c.EndedAt
 2392            }).ToList() ?? new List<CampaignDto>(),
 2393            Members = world.Members?.Select(m => new WorldMemberDto
 2394            {
 2395                Id = m.Id,
 2396                UserId = m.UserId,
 2397                DisplayName = m.User?.DisplayName ?? "Unknown",
 2398                Email = m.User?.Email ?? "",
 2399                AvatarUrl = m.User?.AvatarUrl,
 2400                Role = m.Role,
 2401                JoinedAt = m.JoinedAt,
 2402                InvitedBy = m.InvitedBy
 2403            }).ToList() ?? new List<WorldMemberDto>()
 2404        };
 405    }
 406}