< Summary

Information
Class: Chronicis.Api.Services.ArcService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ArcService.cs
Line coverage
100%
Covered lines: 4
Uncovered lines: 0
Coverable lines: 4
Total lines: 363
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ArcService.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
 11public sealed class ArcService : IArcService
 12{
 13    private readonly ChronicisDbContext _context;
 14    private readonly IReservedSlugProvider _reservedSlugProvider;
 15    private readonly ILogger<ArcService> _logger;
 16
 17    public ArcService(
 18        ChronicisDbContext context,
 19        IReservedSlugProvider reservedSlugProvider,
 20        ILogger<ArcService> logger)
 21    {
 2722        _context = context;
 2723        _reservedSlugProvider = reservedSlugProvider;
 2724        _logger = logger;
 2725    }
 26
 27    /// <summary>
 28    /// Check if user has access to a campaign (via world membership).
 29    /// </summary>
 30    private async Task<bool> UserHasCampaignAccessAsync(Guid campaignId, Guid userId)
 31    {
 32        return await _context.Campaigns
 33            .AnyAsync(c => c.Id == campaignId && c.World.Members.Any(m => m.UserId == userId));
 34    }
 35
 36    /// <summary>
 37    /// Check if user is a GM in the world that owns the campaign.
 38    /// </summary>
 39    private async Task<bool> UserIsCampaignGMAsync(Guid campaignId, Guid userId)
 40    {
 41        return await _context.Campaigns
 42            .AnyAsync(c => c.Id == campaignId && c.World.Members.Any(m => m.UserId == userId && m.Role == WorldRole.GM))
 43    }
 44
 45    /// <summary>
 46    /// Check if user is either the world owner or a GM for the campaign.
 47    /// </summary>
 48    private async Task<bool> UserIsCampaignOwnerOrGMAsync(Guid campaignId, Guid userId)
 49    {
 50        return await _context.Campaigns
 51            .AnyAsync(c => c.Id == campaignId && (c.World.OwnerId == userId
 52                || c.World.Members.Any(m => m.UserId == userId && m.Role == WorldRole.GM)));
 53    }
 54
 55    public async Task<List<ArcDto>> GetArcsByCampaignAsync(Guid campaignId, Guid userId)
 56    {
 57        // Verify user has access to the campaign via world membership
 58        if (!await UserHasCampaignAccessAsync(campaignId, userId))
 59        {
 60            _logger.LogWarningSanitized("Campaign {CampaignId} not found or user {UserId} doesn't have access", campaign
 61            return new List<ArcDto>();
 62        }
 63
 64        return await _context.Arcs
 65            .AsNoTracking()
 66            .Where(a => a.CampaignId == campaignId)
 67            .OrderBy(a => a.SortOrder)
 68            .ThenBy(a => a.CreatedAt)
 69            .Select(a => new ArcDto
 70            {
 71                Id = a.Id,
 72                CampaignId = a.CampaignId,
 73                Name = a.Name,
 74                Description = a.Description,
 75                PrivateNotes = null,
 76                SortOrder = a.SortOrder,
 77                SessionCount = _context.Articles.Count(art => art.ArcId == a.Id),
 78                IsActive = a.IsActive,
 79                CreatedAt = a.CreatedAt,
 80                CreatedBy = a.CreatedBy,
 81                CreatedByName = a.Creator.DisplayName,
 82                Slug = a.Slug,
 83                CampaignSlug = a.Campaign.Slug,
 84                WorldSlug = a.Campaign.World.Slug
 85            })
 86            .ToListAsync();
 87    }
 88
 89    public async Task<ArcDto?> GetArcAsync(Guid arcId, Guid userId)
 90    {
 91        return await _context.Arcs
 92            .AsNoTracking()
 93            .Where(a => a.Id == arcId && a.Campaign.World.Members.Any(m => m.UserId == userId))
 94            .Select(a => new ArcDto
 95            {
 96                Id = a.Id,
 97                CampaignId = a.CampaignId,
 98                Name = a.Name,
 99                Description = a.Description,
 100                PrivateNotes = a.Campaign.World.OwnerId == userId
 101                    || a.Campaign.World.Members.Any(m => m.UserId == userId && m.Role == WorldRole.GM)
 102                    ? a.PrivateNotes
 103                    : null,
 104                SortOrder = a.SortOrder,
 105                SessionCount = _context.Articles.Count(art => art.ArcId == a.Id),
 106                IsActive = a.IsActive,
 107                CreatedAt = a.CreatedAt,
 108                CreatedBy = a.CreatedBy,
 109                CreatedByName = a.Creator.DisplayName,
 110                Slug = a.Slug,
 111                CampaignSlug = a.Campaign.Slug,
 112                WorldSlug = a.Campaign.World.Slug
 113            })
 114            .FirstOrDefaultAsync();
 115    }
 116
 117    public async Task<ArcDto?> CreateArcAsync(ArcCreateDto dto, Guid userId)
 118    {
 119        // Only GMs can create arcs
 120        if (!await UserIsCampaignGMAsync(dto.CampaignId, userId))
 121        {
 122            _logger.LogWarningSanitized("Campaign {CampaignId} not found or user {UserId} is not a GM", dto.CampaignId, 
 123            return null;
 124        }
 125
 126        // Use provided sort order or auto-calculate next
 127        var sortOrder = dto.SortOrder;
 128        if (sortOrder == 0)
 129        {
 130            var maxSortOrder = await _context.Arcs
 131                .Where(a => a.CampaignId == dto.CampaignId)
 132                .MaxAsync(a => (int?)a.SortOrder) ?? 0;
 133            sortOrder = maxSortOrder + 1;
 134        }
 135
 136        var arcSlugBase = !string.IsNullOrWhiteSpace(dto.Slug) && SlugGenerator.IsValidSlug(dto.Slug.Trim())
 137            ? dto.Slug.Trim()
 138            : SlugGenerator.GenerateSlug(dto.Name);
 139        var existingArcSlugs = await _context.Arcs.AsNoTracking()
 140            .Where(a => a.CampaignId == dto.CampaignId)
 141            .Select(a => a.Slug)
 142            .ToHashSetAsync();
 143        var arcSlug = SlugGenerator.GenerateUniqueSiblingSlug(arcSlugBase, existingArcSlugs, _reservedSlugProvider.All);
 144        var arc = new Arc
 145        {
 146            Id = Guid.NewGuid(),
 147            CampaignId = dto.CampaignId,
 148            Name = dto.Name,
 149            Slug = arcSlug,
 150            Description = dto.Description,
 151            SortOrder = sortOrder,
 152            CreatedAt = DateTime.UtcNow,
 153            CreatedBy = userId
 154        };
 155
 156        _context.Arcs.Add(arc);
 157        await _context.SaveChangesAsync();
 158
 159        _logger.LogTraceSanitized("Created arc {ArcId} '{ArcName}' in campaign {CampaignId}",
 160            arc.Id, arc.Name, arc.CampaignId);
 161
 162        var campaignSlugInfo = await _context.Campaigns.AsNoTracking()
 163            .Where(c => c.Id == arc.CampaignId)
 164            .Select(c => new { c.Slug, WorldSlug = c.World.Slug })
 165            .FirstOrDefaultAsync();
 166
 167        return new ArcDto
 168        {
 169            Id = arc.Id,
 170            CampaignId = arc.CampaignId,
 171            Name = arc.Name,
 172            Description = arc.Description,
 173            PrivateNotes = null,
 174            SortOrder = arc.SortOrder,
 175            SessionCount = 0,
 176            IsActive = arc.IsActive,
 177            CreatedAt = arc.CreatedAt,
 178            CreatedBy = arc.CreatedBy,
 179            Slug = arc.Slug,
 180            CampaignSlug = campaignSlugInfo?.Slug ?? string.Empty,
 181            WorldSlug = campaignSlugInfo?.WorldSlug ?? string.Empty
 182        };
 183    }
 184
 185    public async Task<ArcDto?> UpdateArcAsync(Guid arcId, ArcUpdateDto dto, Guid userId)
 186    {
 187        var arc = await _context.Arcs
 188            .Include(a => a.Creator)
 189            .Include(a => a.Campaign)
 190                .ThenInclude(c => c.World)
 191            .FirstOrDefaultAsync(a => a.Id == arcId);
 192
 193        if (arc == null)
 194        {
 195            _logger.LogWarningSanitized("Arc {ArcId} not found", arcId);
 196            return null;
 197        }
 198
 199        if (!await UserIsCampaignOwnerOrGMAsync(arc.CampaignId, userId))
 200        {
 201            _logger.LogWarningSanitized("User {UserId} is not authorized to update arc {ArcId}", userId, arcId);
 202            return null;
 203        }
 204
 205        if (arc.Name != dto.Name)
 206        {
 207            arc.Slug = await GenerateUniqueArcSlugAsync(dto.Name, arc.CampaignId, arc.Id);
 208        }
 209
 210        arc.Name = dto.Name;
 211        arc.Description = dto.Description;
 212        arc.PrivateNotes = string.IsNullOrWhiteSpace(dto.PrivateNotes) ? null : dto.PrivateNotes;
 213
 214        if (dto.SortOrder.HasValue)
 215        {
 216            arc.SortOrder = dto.SortOrder.Value;
 217        }
 218
 219        await _context.SaveChangesAsync();
 220
 221        _logger.LogTraceSanitized("Updated arc {ArcId} '{ArcName}'", arc.Id, arc.Name);
 222
 223        var sessionCount = await _context.Articles.CountAsync(a => a.ArcId == arcId);
 224
 225        return new ArcDto
 226        {
 227            Id = arc.Id,
 228            CampaignId = arc.CampaignId,
 229            Name = arc.Name,
 230            Description = arc.Description,
 231            PrivateNotes = arc.PrivateNotes,
 232            SortOrder = arc.SortOrder,
 233            SessionCount = sessionCount,
 234            IsActive = arc.IsActive,
 235            CreatedAt = arc.CreatedAt,
 236            CreatedBy = arc.CreatedBy,
 237            CreatedByName = arc.Creator.DisplayName,
 238            Slug = arc.Slug,
 239            CampaignSlug = arc.Campaign?.Slug ?? string.Empty,
 240            WorldSlug = arc.Campaign?.World?.Slug ?? string.Empty
 241        };
 242    }
 243
 244    public async Task<bool> DeleteArcAsync(Guid arcId, Guid userId)
 245    {
 246        var arc = await _context.Arcs
 247            .FirstOrDefaultAsync(a => a.Id == arcId);
 248
 249        if (arc == null)
 250        {
 251            _logger.LogWarningSanitized("Arc {ArcId} not found", arcId);
 252            return false;
 253        }
 254
 255        if (!await UserIsCampaignGMAsync(arc.CampaignId, userId))
 256        {
 257            _logger.LogWarningSanitized("User {UserId} is not a GM for arc {ArcId}", userId, arcId);
 258            return false;
 259        }
 260
 261        // Check if arc has sessions
 262        var hasContent = await _context.Articles.AnyAsync(a => a.ArcId == arcId);
 263        if (hasContent)
 264        {
 265            _logger.LogWarningSanitized("Cannot delete arc {ArcId} - it has sessions", arcId);
 266            return false;
 267        }
 268
 269        _context.Arcs.Remove(arc);
 270        await _context.SaveChangesAsync();
 271
 272        _logger.LogTraceSanitized("Deleted arc {ArcId}", arcId);
 273        return true;
 274    }
 275
 276    public async Task<bool> ActivateArcAsync(Guid arcId, Guid userId)
 277    {
 278        var arc = await _context.Arcs
 279            .Include(a => a.Campaign)
 280                .ThenInclude(c => c.World)
 281                    .ThenInclude(w => w.Members)
 282            .FirstOrDefaultAsync(a => a.Id == arcId);
 283
 284        if (arc == null)
 285            return false;
 286
 287        // Only the world owner or GMs can activate arcs
 288        if (!(arc.Campaign.World.OwnerId == userId
 289            || arc.Campaign.World.Members.Any(m => m.UserId == userId && m.Role == WorldRole.GM)))
 290            return false;
 291
 292        // Deactivate all arcs in the same campaign
 293        var campaignArcs = await _context.Arcs
 294            .Where(a => a.CampaignId == arc.CampaignId && a.IsActive)
 295            .ToListAsync();
 296
 297        foreach (var a in campaignArcs)
 298        {
 299            a.IsActive = false;
 300        }
 301
 302        // Activate this arc
 303        arc.IsActive = true;
 304
 305        await _context.SaveChangesAsync();
 306
 307        _logger.LogTraceSanitized("Activated arc {ArcId} in campaign {CampaignId}", arcId, arc.CampaignId);
 308
 309        return true;
 310    }
 311
 312    public async Task<(Guid Id, string Name)?> GetIdBySlugAsync(Guid campaignId, string slug)
 313    {
 314        var row = await _context.Arcs.AsNoTracking()
 315            .Where(a => a.CampaignId == campaignId && a.Slug == slug)
 316            .Select(a => new { a.Id, a.Name })
 317            .FirstOrDefaultAsync();
 318
 319        return row == null ? null : (row.Id, row.Name);
 320    }
 321
 322    public async Task<ServiceResult<string>> UpdateSlugAsync(Guid arcId, string slug, Guid userId)
 323    {
 324        var arc = await _context.Arcs.AsNoTracking()
 325            .FirstOrDefaultAsync(a => a.Id == arcId);
 326
 327        if (arc == null)
 328            return ServiceResult<string>.NotFound("Arc not found");
 329
 330        if (!await UserIsCampaignOwnerOrGMAsync(arc.CampaignId, userId))
 331            return ServiceResult<string>.Forbidden("Only the world owner or GM may update the slug");
 332
 333        if (!SlugGenerator.IsValidSlug(slug))
 334            return ServiceResult<string>.ValidationError("SLUG_INVALID");
 335
 336        if (_reservedSlugProvider.IsReserved(slug))
 337            return ServiceResult<string>.ValidationError("SLUG_RESERVED");
 338
 339        var existing = await _context.Arcs.AsNoTracking()
 340            .Where(a => a.CampaignId == arc.CampaignId && a.Id != arcId)
 341            .Select(a => a.Slug)
 342            .ToHashSetAsync();
 343
 344        var finalSlug = SlugGenerator.GenerateUniqueSiblingSlug(slug, existing, _reservedSlugProvider.All);
 345
 346        var tracked = await _context.Arcs.FirstAsync(a => a.Id == arcId);
 347        tracked.Slug = finalSlug;
 348        await _context.SaveChangesAsync();
 349
 350        return ServiceResult<string>.Success(finalSlug);
 351    }
 352
 353    private async Task<string> GenerateUniqueArcSlugAsync(string name, Guid campaignId, Guid? excludeId = null)
 354    {
 355        var existing = await _context.Arcs.AsNoTracking()
 356            .Where(a => a.CampaignId == campaignId && (!excludeId.HasValue || a.Id != excludeId.Value))
 357            .Select(a => a.Slug)
 358            .ToHashSetAsync();
 359
 360        return SlugGenerator.GenerateUniqueSiblingSlug(
 361            SlugGenerator.GenerateSlug(name), existing, _reservedSlugProvider.All);
 362    }
 363}