< Summary

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

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Api.Infrastructure;
 3using Chronicis.Shared.DTOs;
 4using Microsoft.EntityFrameworkCore;
 5
 6namespace Chronicis.Api.Services;
 7
 8/// <summary>
 9/// Implementation of system administrator operations.
 10/// Every public method verifies sysadmin status via <see cref="ICurrentUserService"/>
 11/// before performing any work.
 12/// </summary>
 13public sealed class AdminService : IAdminService
 14{
 15    private readonly ChronicisDbContext _context;
 16    private readonly ICurrentUserService _currentUserService;
 17    private readonly ILogger<AdminService> _logger;
 18
 19    public AdminService(
 20        ChronicisDbContext context,
 21        ICurrentUserService currentUserService,
 22        ILogger<AdminService> logger)
 23    {
 1524        _context = context;
 1525        _currentUserService = currentUserService;
 1526        _logger = logger;
 1527    }
 28
 29    /// <inheritdoc/>
 30    public async Task<List<AdminWorldSummaryDto>> GetAllWorldSummariesAsync()
 31    {
 32        await ThrowIfNotSysAdminAsync();
 33
 34        _logger.LogTraceSanitized("SysAdmin fetching all world summaries");
 35
 36        return await BuildWorldSummaryQueryAsync();
 37    }
 38
 39    /// <inheritdoc/>
 40    public async Task<bool> DeleteWorldAsync(Guid worldId)
 41    {
 42        await ThrowIfNotSysAdminAsync();
 43
 44        var world = await _context.Worlds.FindAsync(worldId);
 45        if (world == null)
 46        {
 47            _logger.LogTraceSanitized("SysAdmin delete: world {WorldId} not found", worldId);
 48            return false;
 49        }
 50
 51        _logger.LogWarningSanitized("SysAdmin permanently deleting world {WorldId} ({WorldName})",
 52            worldId, world.Name);
 53
 54        await DeleteWorldDataAsync(worldId);
 55
 56        _context.Worlds.Remove(world);
 57        await _context.SaveChangesAsync();
 58
 59        _logger.LogWarningSanitized("SysAdmin permanently deleted world {WorldId}", worldId);
 60        return true;
 61    }
 62
 63    // ────────────────────────────────────────────────────────────────
 64    //  Private helpers
 65    // ────────────────────────────────────────────────────────────────
 66
 67    private async Task ThrowIfNotSysAdminAsync()
 68    {
 69        if (!await _currentUserService.IsSysAdminAsync())
 70            throw new UnauthorizedAccessException("Caller is not a system administrator.");
 71    }
 72
 73    /// <summary>
 74    /// Builds the world summary query using a single set of aggregate subqueries,
 75    /// avoiding N+1 correlated queries per world.
 76    /// </summary>
 77    internal async Task<List<AdminWorldSummaryDto>> BuildWorldSummaryQueryAsync()
 78    {
 79        var summaries = await _context.Worlds
 80            .AsNoTracking()
 81            .Select(w => new AdminWorldSummaryDto
 82            {
 83                Id = w.Id,
 84                Name = w.Name,
 85                OwnerName = w.Owner != null ? w.Owner.DisplayName : "Unknown",
 86                OwnerEmail = w.Owner != null ? w.Owner.Email : string.Empty,
 87                CampaignCount = w.Campaigns.Count,
 88                ArcCount = w.Campaigns.SelectMany(c => c.Arcs).Count(),
 89                ArticleCount = w.Articles.Count,
 90                CreatedAt = w.CreatedAt,
 91            })
 92            .OrderBy(s => s.Name)
 93            .ToListAsync();
 94
 95        return summaries;
 96    }
 97
 98
 99    /// <summary>
 100    /// Deletes world-owned data in an order that satisfies FK constraints.
 101    /// EF cascade handles: WorldMembers, WorldInvitations, WorldDocuments,
 102    /// WorldLinks, WorldResourceProviders, SummaryTemplates, Arcs→Quests→QuestUpdates.
 103    /// We must handle manually: ArticleLinks (NoAction FK), then Articles,
 104    /// then Campaigns (Restrict FK to World).
 105    /// </summary>
 106    private async Task DeleteWorldDataAsync(Guid worldId)
 107    {
 108        // 1. Article links — target FK is NoAction; delete before articles
 109        var articleIds = _context.Articles
 110            .Where(a => a.WorldId == worldId)
 111            .Select(a => a.Id);
 112
 113        var incomingLinks = _context.ArticleLinks
 114            .Where(al => articleIds.Contains(al.TargetArticleId));
 115        _context.ArticleLinks.RemoveRange(incomingLinks);
 116        await _context.SaveChangesAsync();
 117
 118        // 2. Articles — Restrict FK to World; must remove before world
 119        var articles = _context.Articles.Where(a => a.WorldId == worldId);
 120        _context.Articles.RemoveRange(articles);
 121        await _context.SaveChangesAsync();
 122
 123        // 3. Campaigns — Restrict FK to World; Arcs/Quests/QuestUpdates cascade
 124        var campaigns = _context.Campaigns.Where(c => c.WorldId == worldId);
 125        _context.Campaigns.RemoveRange(campaigns);
 126        await _context.SaveChangesAsync();
 127    }
 128}