< 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: 43
Uncovered lines: 0
Coverable lines: 43
Total lines: 317
Line coverage: 100%
Branch coverage
100%
Covered branches: 16
Total branches: 16
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%66100%
MapToDetailDto(...)100%1010100%

File(s)

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

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Shared.DTOs;
 3using Chronicis.Shared.Enums;
 4using Chronicis.Shared.Models;
 5using Microsoft.EntityFrameworkCore;
 6
 7namespace Chronicis.Api.Services;
 8
 9/// <summary>
 10/// Service for campaign management
 11/// </summary>
 12public sealed class CampaignService : ICampaignService
 13{
 14    private readonly ChronicisDbContext _context;
 15    private readonly ILogger<CampaignService> _logger;
 16
 17    public CampaignService(ChronicisDbContext context, ILogger<CampaignService> logger)
 18    {
 2619        _context = context;
 2620        _logger = logger;
 2621    }
 22
 23    public async Task<CampaignDetailDto?> GetCampaignAsync(Guid campaignId, Guid userId)
 24    {
 25        var campaign = await _context.Campaigns
 26            .Include(c => c.Owner)
 27            .Include(c => c.Arcs)
 28            .Include(c => c.World)
 29                .ThenInclude(w => w.Members)
 30            .FirstOrDefaultAsync(c => c.Id == campaignId);
 31
 32        if (campaign == null)
 33            return null;
 34
 35        // Check access via world membership
 36        if (!await UserHasAccessAsync(campaignId, userId))
 37            return null;
 38
 39        var canViewPrivateNotes = campaign.World.OwnerId == userId
 40            || campaign.World.Members.Any(m => m.UserId == userId && m.Role == WorldRole.GM);
 41
 42        var dto = MapToDetailDto(campaign);
 43        if (!canViewPrivateNotes)
 44        {
 45            dto.PrivateNotes = null;
 46        }
 47
 48        return dto;
 49    }
 50
 51    public async Task<CampaignDto> CreateCampaignAsync(CampaignCreateDto dto, Guid userId)
 52    {
 53        // Verify user is GM in the world
 54        var world = await _context.Worlds.FindAsync(dto.WorldId);
 55        if (world == null)
 56            throw new InvalidOperationException("World not found");
 57
 58        var isGM = await _context.WorldMembers
 59            .AnyAsync(wm => wm.WorldId == dto.WorldId && wm.UserId == userId && wm.Role == WorldRole.GM);
 60
 61        if (!isGM)
 62            throw new UnauthorizedAccessException("Only GMs can create campaigns");
 63
 64        var user = await _context.Users.FindAsync(userId);
 65        if (user == null)
 66            throw new InvalidOperationException("User not found");
 67
 68        _logger.LogTraceSanitized("Creating campaign '{Name}' in world {WorldId} for user {UserId}",
 69            dto.Name, dto.WorldId, userId);
 70
 71        // Create the Campaign entity
 72        var campaign = new Campaign
 73        {
 74            Id = Guid.NewGuid(),
 75            WorldId = dto.WorldId,
 76            Name = dto.Name,
 77            Description = dto.Description,
 78            OwnerId = userId,
 79            CreatedAt = DateTime.UtcNow
 80        };
 81        _context.Campaigns.Add(campaign);
 82
 83        // Create a default Arc (Act 1)
 84        var defaultArc = new Arc
 85        {
 86            Id = Guid.NewGuid(),
 87            CampaignId = campaign.Id,
 88            Name = "Act 1",
 89            Description = null,
 90            SortOrder = 1,
 91            CreatedAt = DateTime.UtcNow,
 92            CreatedBy = userId
 93        };
 94        _context.Arcs.Add(defaultArc);
 95
 96        await _context.SaveChangesAsync();
 97
 98        _logger.LogTraceSanitized("Created campaign {CampaignId} with default Arc for user {UserId}",
 99            campaign.Id, userId);
 100
 101        // Return DTO
 102        campaign.Owner = user;
 103        return MapToDto(campaign);
 104    }
 105
 106    public async Task<CampaignDto?> UpdateCampaignAsync(Guid campaignId, CampaignUpdateDto dto, Guid userId)
 107    {
 108        var campaign = await _context.Campaigns
 109            .Include(c => c.Owner)
 110            .FirstOrDefaultAsync(c => c.Id == campaignId);
 111
 112        if (campaign == null)
 113            return null;
 114
 115        // Only world owner or GM can update
 116        if (!await UserIsWorldOwnerOrGMAsync(campaignId, userId))
 117            return null;
 118
 119        campaign.Name = dto.Name;
 120        campaign.Description = dto.Description;
 121        campaign.PrivateNotes = string.IsNullOrWhiteSpace(dto.PrivateNotes) ? null : dto.PrivateNotes;
 122        campaign.StartedAt = dto.StartedAt;
 123        campaign.EndedAt = dto.EndedAt;
 124
 125        await _context.SaveChangesAsync();
 126
 127        _logger.LogTraceSanitized("Updated campaign {CampaignId}", campaignId);
 128
 129        return MapToDto(campaign);
 130    }
 131
 132    public async Task<WorldRole?> GetUserRoleAsync(Guid campaignId, Guid userId)
 133    {
 134        // Get the world for this campaign, then check world membership
 135        var campaign = await _context.Campaigns.FindAsync(campaignId);
 136        if (campaign == null)
 137            return null;
 138
 139        var member = await _context.WorldMembers
 140            .FirstOrDefaultAsync(wm => wm.WorldId == campaign.WorldId && wm.UserId == userId);
 141
 142        return member?.Role;
 143    }
 144
 145    public async Task<bool> UserHasAccessAsync(Guid campaignId, Guid userId)
 146    {
 147        // User has access if they're a member of the campaign's world
 148        var campaign = await _context.Campaigns.FindAsync(campaignId);
 149        if (campaign == null)
 150            return false;
 151
 152        return await _context.WorldMembers
 153            .AnyAsync(wm => wm.WorldId == campaign.WorldId && wm.UserId == userId);
 154    }
 155
 156    public async Task<bool> UserIsGMAsync(Guid campaignId, Guid userId)
 157    {
 158        // User is GM if they have GM role in the campaign's world
 159        var campaign = await _context.Campaigns.FindAsync(campaignId);
 160        if (campaign == null)
 161            return false;
 162
 163        return await _context.WorldMembers
 164            .AnyAsync(wm => wm.WorldId == campaign.WorldId
 165                        && wm.UserId == userId
 166                        && wm.Role == WorldRole.GM);
 167    }
 168
 169    private async Task<bool> UserIsWorldOwnerOrGMAsync(Guid campaignId, Guid userId)
 170    {
 171        var campaign = await _context.Campaigns
 172            .AsNoTracking()
 173            .Include(c => c.World)
 174            .FirstOrDefaultAsync(c => c.Id == campaignId);
 175
 176        if (campaign == null)
 177            return false;
 178
 179        if (campaign.World.OwnerId == userId)
 180            return true;
 181
 182        return await _context.WorldMembers
 183            .AnyAsync(wm => wm.WorldId == campaign.WorldId
 184                && wm.UserId == userId
 185                && wm.Role == WorldRole.GM);
 186    }
 187
 188    public async Task<bool> ActivateCampaignAsync(Guid campaignId, Guid userId)
 189    {
 190        var campaign = await _context.Campaigns
 191            .FirstOrDefaultAsync(c => c.Id == campaignId);
 192
 193        if (campaign == null)
 194            return false;
 195
 196        // Only the world owner or a GM can activate
 197        if (!await UserIsWorldOwnerOrGMAsync(campaignId, userId))
 198            return false;
 199
 200        // Deactivate all campaigns in the same world
 201        var worldCampaigns = await _context.Campaigns
 202            .Where(c => c.WorldId == campaign.WorldId && c.IsActive)
 203            .ToListAsync();
 204
 205        foreach (var c in worldCampaigns)
 206        {
 207            c.IsActive = false;
 208        }
 209
 210        // Activate this campaign
 211        campaign.IsActive = true;
 212
 213        await _context.SaveChangesAsync();
 214
 215        _logger.LogTraceSanitized("Activated campaign {CampaignId} in world {WorldId}", campaignId, campaign.WorldId);
 216
 217        return true;
 218    }
 219
 220    public async Task<ActiveContextDto> GetActiveContextAsync(Guid worldId, Guid userId)
 221    {
 222        var result = new ActiveContextDto();
 223        result.WorldId = worldId;
 224
 225        // Check if user has access to this world
 226        var hasAccess = await _context.WorldMembers
 227            .AnyAsync(wm => wm.WorldId == worldId && wm.UserId == userId);
 228
 229        if (!hasAccess)
 230            return result;
 231
 232        // Get all campaigns in this world
 233        var campaigns = await _context.Campaigns
 234            .Where(c => c.WorldId == worldId)
 235            .ToListAsync();
 236
 237        if (campaigns.Count == 0)
 238            return result;
 239
 240        // Find explicitly active campaign only
 241        Campaign? activeCampaign = campaigns.FirstOrDefault(c => c.IsActive);
 242
 243        if (activeCampaign == null)
 244            return result;
 245
 246        result.CampaignId = activeCampaign.Id;
 247        result.CampaignName = activeCampaign.Name;
 248
 249        // Get arcs for the active campaign
 250        var arcs = await _context.Arcs
 251            .Where(a => a.CampaignId == activeCampaign.Id)
 252            .OrderBy(a => a.SortOrder)
 253            .ToListAsync();
 254
 255        if (arcs.Count == 0)
 256            return result;
 257
 258        // Find explicitly active arc only
 259        Arc? activeArc = arcs.FirstOrDefault(a => a.IsActive);
 260
 261        if (activeArc != null)
 262        {
 263            result.ArcId = activeArc.Id;
 264            result.ArcName = activeArc.Name;
 265        }
 266
 267        return result;
 268    }
 269
 270    private static CampaignDto MapToDto(Campaign campaign)
 271    {
 4272        return new CampaignDto
 4273        {
 4274            Id = campaign.Id,
 4275            WorldId = campaign.WorldId,
 4276            Name = campaign.Name,
 4277            Description = campaign.Description,
 4278            OwnerId = campaign.OwnerId,
 4279            OwnerName = campaign.Owner?.DisplayName ?? "Unknown",
 4280            CreatedAt = campaign.CreatedAt,
 4281            StartedAt = campaign.StartedAt,
 4282            EndedAt = campaign.EndedAt,
 4283            IsActive = campaign.IsActive,
 4284            ArcCount = campaign.Arcs?.Count ?? 0
 4285        };
 286    }
 287
 288    private static CampaignDetailDto MapToDetailDto(Campaign campaign)
 289    {
 2290        return new CampaignDetailDto
 2291        {
 2292            Id = campaign.Id,
 2293            WorldId = campaign.WorldId,
 2294            Name = campaign.Name,
 2295            Description = campaign.Description,
 2296            PrivateNotes = campaign.PrivateNotes,
 2297            OwnerId = campaign.OwnerId,
 2298            OwnerName = campaign.Owner?.DisplayName ?? "Unknown",
 2299            CreatedAt = campaign.CreatedAt,
 2300            StartedAt = campaign.StartedAt,
 2301            EndedAt = campaign.EndedAt,
 2302            IsActive = campaign.IsActive,
 2303            ArcCount = campaign.Arcs?.Count ?? 0,
 2304            Arcs = campaign.Arcs?.Select(a => new ArcDto
 2305            {
 2306                Id = a.Id,
 2307                CampaignId = a.CampaignId,
 2308                Name = a.Name,
 2309                Description = a.Description,
 2310                PrivateNotes = null,
 2311                SortOrder = a.SortOrder,
 2312                IsActive = a.IsActive,
 2313                CreatedAt = a.CreatedAt
 2314            }).ToList() ?? new List<ArcDto>()
 2315        };
 316    }
 317}