< Summary

Information
Class: Chronicis.Api.Services.CampaignService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/CampaignService.cs
Line coverage
100%
Covered lines: 51
Uncovered lines: 0
Coverable lines: 51
Total lines: 414
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%1010100%
MapToDetailDto(...)100%1414100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/CampaignService.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 campaign management
 13/// </summary>
 14public sealed class CampaignService : ICampaignService
 15{
 16    private readonly ChronicisDbContext _context;
 17    private readonly IReservedSlugProvider _reservedSlugProvider;
 18    private readonly ILogger<CampaignService> _logger;
 19
 20    public CampaignService(
 21        ChronicisDbContext context,
 22        IReservedSlugProvider reservedSlugProvider,
 23        ILogger<CampaignService> logger)
 24    {
 3325        _context = context;
 3326        _reservedSlugProvider = reservedSlugProvider;
 3327        _logger = logger;
 3328    }
 29
 30    public async Task<CampaignDetailDto?> GetCampaignAsync(Guid campaignId, Guid userId)
 31    {
 32        var campaign = await _context.Campaigns
 33            .Include(c => c.Owner)
 34            .Include(c => c.Arcs)
 35            .Include(c => c.World)
 36                .ThenInclude(w => w.Members)
 37            .FirstOrDefaultAsync(c => c.Id == campaignId);
 38
 39        if (campaign == null)
 40            return null;
 41
 42        // Check access via world membership
 43        if (!await UserHasAccessAsync(campaignId, userId))
 44            return null;
 45
 46        var canViewPrivateNotes = campaign.World.OwnerId == userId
 47            || campaign.World.Members.Any(m => m.UserId == userId && m.Role == WorldRole.GM);
 48
 49        var dto = MapToDetailDto(campaign);
 50        if (!canViewPrivateNotes)
 51        {
 52            dto.PrivateNotes = null;
 53        }
 54
 55        return dto;
 56    }
 57
 58    public async Task<CampaignDto> CreateCampaignAsync(CampaignCreateDto dto, Guid userId)
 59    {
 60        // Verify user is GM in the world
 61        var world = await _context.Worlds.FindAsync(dto.WorldId);
 62        if (world == null)
 63            throw new InvalidOperationException("World not found");
 64
 65        var isGM = await _context.WorldMembers
 66            .AnyAsync(wm => wm.WorldId == dto.WorldId && wm.UserId == userId && wm.Role == WorldRole.GM);
 67
 68        if (!isGM)
 69            throw new UnauthorizedAccessException("Only GMs can create campaigns");
 70
 71        var user = await _context.Users.FindAsync(userId);
 72        if (user == null)
 73            throw new InvalidOperationException("User not found");
 74
 75        _logger.LogTraceSanitized("Creating campaign '{Name}' in world {WorldId} for user {UserId}",
 76            dto.Name, dto.WorldId, userId);
 77
 78        // Create the Campaign entity — use caller-supplied slug as seed if valid
 79        var slugBase = !string.IsNullOrWhiteSpace(dto.Slug) && SlugGenerator.IsValidSlug(dto.Slug.Trim())
 80            ? dto.Slug.Trim()
 81            : SlugGenerator.GenerateSlug(dto.Name);
 82        var existing = await _context.Campaigns.AsNoTracking()
 83            .Where(c => c.WorldId == dto.WorldId)
 84            .Select(c => c.Slug)
 85            .ToHashSetAsync();
 86        var campaignSlug = SlugGenerator.GenerateUniqueSiblingSlug(slugBase, existing, _reservedSlugProvider.All);
 87        var campaign = new Campaign
 88        {
 89            Id = Guid.NewGuid(),
 90            WorldId = dto.WorldId,
 91            Name = dto.Name,
 92            Slug = campaignSlug,
 93            Description = dto.Description,
 94            OwnerId = userId,
 95            CreatedAt = DateTime.UtcNow
 96        };
 97        _context.Campaigns.Add(campaign);
 98
 99        // Create a default Arc (Act 1)
 100        var arcSlug = await GenerateUniqueArcSlugAsync("Act 1", campaign.Id);
 101        var defaultArc = new Arc
 102        {
 103            Id = Guid.NewGuid(),
 104            CampaignId = campaign.Id,
 105            Name = "Act 1",
 106            Slug = arcSlug,
 107            Description = null,
 108            SortOrder = 1,
 109            CreatedAt = DateTime.UtcNow,
 110            CreatedBy = userId
 111        };
 112        _context.Arcs.Add(defaultArc);
 113
 114        await _context.SaveChangesAsync();
 115
 116        _logger.LogTraceSanitized("Created campaign {CampaignId} with default Arc for user {UserId}",
 117            campaign.Id, userId);
 118
 119        // Return DTO
 120        campaign.Owner = user;
 121        campaign.World = world;
 122        return MapToDto(campaign);
 123    }
 124
 125    public async Task<CampaignDto?> UpdateCampaignAsync(Guid campaignId, CampaignUpdateDto dto, Guid userId)
 126    {
 127        var campaign = await _context.Campaigns
 128            .Include(c => c.Owner)
 129            .Include(c => c.World)
 130            .FirstOrDefaultAsync(c => c.Id == campaignId);
 131
 132        if (campaign == null)
 133            return null;
 134
 135        // Only world owner or GM can update
 136        if (!await UserIsWorldOwnerOrGMAsync(campaignId, userId))
 137            return null;
 138
 139        if (campaign.Name != dto.Name)
 140        {
 141            campaign.Slug = await GenerateUniqueCampaignSlugAsync(dto.Name, campaign.WorldId, campaign.Id);
 142        }
 143
 144        campaign.Name = dto.Name;
 145        campaign.Description = dto.Description;
 146        campaign.PrivateNotes = string.IsNullOrWhiteSpace(dto.PrivateNotes) ? null : dto.PrivateNotes;
 147        campaign.StartedAt = dto.StartedAt;
 148        campaign.EndedAt = dto.EndedAt;
 149
 150        await _context.SaveChangesAsync();
 151
 152        _logger.LogTraceSanitized("Updated campaign {CampaignId}", campaignId);
 153
 154        return MapToDto(campaign);
 155    }
 156
 157    public async Task<WorldRole?> GetUserRoleAsync(Guid campaignId, Guid userId)
 158    {
 159        // Get the world for this campaign, then check world membership
 160        var campaign = await _context.Campaigns.FindAsync(campaignId);
 161        if (campaign == null)
 162            return null;
 163
 164        var member = await _context.WorldMembers
 165            .FirstOrDefaultAsync(wm => wm.WorldId == campaign.WorldId && wm.UserId == userId);
 166
 167        return member?.Role;
 168    }
 169
 170    public async Task<bool> UserHasAccessAsync(Guid campaignId, Guid userId)
 171    {
 172        // User has access if they're a member of the campaign's world
 173        var campaign = await _context.Campaigns.FindAsync(campaignId);
 174        if (campaign == null)
 175            return false;
 176
 177        return await _context.WorldMembers
 178            .AnyAsync(wm => wm.WorldId == campaign.WorldId && wm.UserId == userId);
 179    }
 180
 181    public async Task<bool> UserIsGMAsync(Guid campaignId, Guid userId)
 182    {
 183        // User is GM if they have GM role in the campaign's world
 184        var campaign = await _context.Campaigns.FindAsync(campaignId);
 185        if (campaign == null)
 186            return false;
 187
 188        return await _context.WorldMembers
 189            .AnyAsync(wm => wm.WorldId == campaign.WorldId
 190                        && wm.UserId == userId
 191                        && wm.Role == WorldRole.GM);
 192    }
 193
 194    private async Task<bool> UserIsWorldOwnerOrGMAsync(Guid campaignId, Guid userId)
 195    {
 196        var campaign = await _context.Campaigns
 197            .AsNoTracking()
 198            .Include(c => c.World)
 199            .FirstOrDefaultAsync(c => c.Id == campaignId);
 200
 201        if (campaign == null)
 202            return false;
 203
 204        if (campaign.World.OwnerId == userId)
 205            return true;
 206
 207        return await _context.WorldMembers
 208            .AnyAsync(wm => wm.WorldId == campaign.WorldId
 209                && wm.UserId == userId
 210                && wm.Role == WorldRole.GM);
 211    }
 212
 213    public async Task<bool> ActivateCampaignAsync(Guid campaignId, Guid userId)
 214    {
 215        var campaign = await _context.Campaigns
 216            .FirstOrDefaultAsync(c => c.Id == campaignId);
 217
 218        if (campaign == null)
 219            return false;
 220
 221        // Only the world owner or a GM can activate
 222        if (!await UserIsWorldOwnerOrGMAsync(campaignId, userId))
 223            return false;
 224
 225        // Deactivate all campaigns in the same world
 226        var worldCampaigns = await _context.Campaigns
 227            .Where(c => c.WorldId == campaign.WorldId && c.IsActive)
 228            .ToListAsync();
 229
 230        foreach (var c in worldCampaigns)
 231        {
 232            c.IsActive = false;
 233        }
 234
 235        // Activate this campaign
 236        campaign.IsActive = true;
 237
 238        await _context.SaveChangesAsync();
 239
 240        _logger.LogTraceSanitized("Activated campaign {CampaignId} in world {WorldId}", campaignId, campaign.WorldId);
 241
 242        return true;
 243    }
 244
 245    public async Task<ActiveContextDto> GetActiveContextAsync(Guid worldId, Guid userId)
 246    {
 247        var result = new ActiveContextDto();
 248        result.WorldId = worldId;
 249
 250        // Check if user has access to this world
 251        var hasAccess = await _context.WorldMembers
 252            .AnyAsync(wm => wm.WorldId == worldId && wm.UserId == userId);
 253
 254        if (!hasAccess)
 255            return result;
 256
 257        // Get all campaigns in this world
 258        var campaigns = await _context.Campaigns
 259            .Where(c => c.WorldId == worldId)
 260            .ToListAsync();
 261
 262        if (campaigns.Count == 0)
 263            return result;
 264
 265        // Find explicitly active campaign only
 266        Campaign? activeCampaign = campaigns.FirstOrDefault(c => c.IsActive);
 267
 268        if (activeCampaign == null)
 269            return result;
 270
 271        result.CampaignId = activeCampaign.Id;
 272        result.CampaignName = activeCampaign.Name;
 273
 274        // Get arcs for the active campaign
 275        var arcs = await _context.Arcs
 276            .Where(a => a.CampaignId == activeCampaign.Id)
 277            .OrderBy(a => a.SortOrder)
 278            .ToListAsync();
 279
 280        if (arcs.Count == 0)
 281            return result;
 282
 283        // Find explicitly active arc only
 284        Arc? activeArc = arcs.FirstOrDefault(a => a.IsActive);
 285
 286        if (activeArc != null)
 287        {
 288            result.ArcId = activeArc.Id;
 289            result.ArcName = activeArc.Name;
 290        }
 291
 292        return result;
 293    }
 294
 295    public async Task<(Guid Id, string Name)?> GetIdBySlugAsync(Guid worldId, string slug)
 296    {
 297        var row = await _context.Campaigns.AsNoTracking()
 298            .Where(c => c.WorldId == worldId && c.Slug == slug)
 299            .Select(c => new { c.Id, c.Name })
 300            .FirstOrDefaultAsync();
 301
 302        return row == null ? null : (row.Id, row.Name);
 303    }
 304
 305    public async Task<ServiceResult<string>> UpdateSlugAsync(Guid campaignId, string slug, Guid userId)
 306    {
 307        var campaign = await _context.Campaigns
 308            .AsNoTracking()
 309            .Include(c => c.World)
 310            .FirstOrDefaultAsync(c => c.Id == campaignId);
 311
 312        if (campaign == null)
 313            return ServiceResult<string>.NotFound("Campaign not found");
 314
 315        if (!await UserIsWorldOwnerOrGMAsync(campaignId, userId))
 316            return ServiceResult<string>.Forbidden("Only the world owner or GM may update the slug");
 317
 318        if (!SlugGenerator.IsValidSlug(slug))
 319            return ServiceResult<string>.ValidationError("SLUG_INVALID");
 320
 321        if (_reservedSlugProvider.IsReserved(slug))
 322            return ServiceResult<string>.ValidationError("SLUG_RESERVED");
 323
 324        var existing = await _context.Campaigns.AsNoTracking()
 325            .Where(c => c.WorldId == campaign.WorldId && c.Id != campaignId)
 326            .Select(c => c.Slug)
 327            .ToHashSetAsync();
 328
 329        var finalSlug = SlugGenerator.GenerateUniqueSiblingSlug(slug, existing, _reservedSlugProvider.All);
 330
 331        var tracked = await _context.Campaigns.FirstAsync(c => c.Id == campaignId);
 332        tracked.Slug = finalSlug;
 333        await _context.SaveChangesAsync();
 334
 335        return ServiceResult<string>.Success(finalSlug);
 336    }
 337
 338    private async Task<string> GenerateUniqueCampaignSlugAsync(string name, Guid worldId, Guid? excludeId = null)
 339    {
 340        var existing = await _context.Campaigns.AsNoTracking()
 341            .Where(c => c.WorldId == worldId && (!excludeId.HasValue || c.Id != excludeId.Value))
 342            .Select(c => c.Slug)
 343            .ToHashSetAsync();
 344
 345        return SlugGenerator.GenerateUniqueSiblingSlug(
 346            SlugGenerator.GenerateSlug(name), existing, _reservedSlugProvider.All);
 347    }
 348
 349    internal async Task<string> GenerateUniqueArcSlugAsync(string name, Guid campaignId, Guid? excludeId = null)
 350    {
 351        var existing = await _context.Arcs.AsNoTracking()
 352            .Where(a => a.CampaignId == campaignId && (!excludeId.HasValue || a.Id != excludeId.Value))
 353            .Select(a => a.Slug)
 354            .ToHashSetAsync();
 355
 356        return SlugGenerator.GenerateUniqueSiblingSlug(
 357            SlugGenerator.GenerateSlug(name), existing, _reservedSlugProvider.All);
 358    }
 359
 360    private static CampaignDto MapToDto(Campaign campaign)
 361    {
 5362        return new CampaignDto
 5363        {
 5364            Id = campaign.Id,
 5365            WorldId = campaign.WorldId,
 5366            Name = campaign.Name,
 5367            Description = campaign.Description,
 5368            OwnerId = campaign.OwnerId,
 5369            OwnerName = campaign.Owner?.DisplayName ?? "Unknown",
 5370            CreatedAt = campaign.CreatedAt,
 5371            StartedAt = campaign.StartedAt,
 5372            EndedAt = campaign.EndedAt,
 5373            IsActive = campaign.IsActive,
 5374            ArcCount = campaign.Arcs?.Count ?? 0,
 5375            Slug = campaign.Slug,
 5376            WorldSlug = campaign.World?.Slug ?? string.Empty
 5377        };
 378    }
 379
 380    private static CampaignDetailDto MapToDetailDto(Campaign campaign)
 381    {
 2382        return new CampaignDetailDto
 2383        {
 2384            Id = campaign.Id,
 2385            WorldId = campaign.WorldId,
 2386            Name = campaign.Name,
 2387            Description = campaign.Description,
 2388            PrivateNotes = campaign.PrivateNotes,
 2389            OwnerId = campaign.OwnerId,
 2390            OwnerName = campaign.Owner?.DisplayName ?? "Unknown",
 2391            CreatedAt = campaign.CreatedAt,
 2392            StartedAt = campaign.StartedAt,
 2393            EndedAt = campaign.EndedAt,
 2394            IsActive = campaign.IsActive,
 2395            ArcCount = campaign.Arcs?.Count ?? 0,
 2396            Slug = campaign.Slug,
 2397            WorldSlug = campaign.World?.Slug ?? string.Empty,
 2398            Arcs = campaign.Arcs?.Select(a => new ArcDto
 2399            {
 2400                Id = a.Id,
 2401                CampaignId = a.CampaignId,
 2402                Name = a.Name,
 2403                Description = a.Description,
 2404                PrivateNotes = null,
 2405                SortOrder = a.SortOrder,
 2406                IsActive = a.IsActive,
 2407                CreatedAt = a.CreatedAt,
 2408                Slug = a.Slug,
 2409                CampaignSlug = campaign.Slug,
 2410                WorldSlug = campaign.World?.Slug ?? string.Empty
 2411            }).ToList() ?? new List<ArcDto>()
 2412        };
 413    }
 414}