< Summary

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

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Api.Models;
 3using Chronicis.Shared.DTOs.Quests;
 4using Chronicis.Shared.Enums;
 5using Chronicis.Shared.Models;
 6using Microsoft.EntityFrameworkCore;
 7
 8namespace Chronicis.Api.Services;
 9
 10/// <summary>
 11/// Service for QuestUpdate operations with world membership authorization.
 12/// </summary>
 13public sealed class QuestUpdateService : IQuestUpdateService
 14{
 15    private readonly ChronicisDbContext _context;
 16    private readonly ILogger<QuestUpdateService> _logger;
 17
 18    public QuestUpdateService(ChronicisDbContext context, ILogger<QuestUpdateService> logger)
 19    {
 1720        _context = context;
 1721        _logger = logger;
 1722    }
 23
 24    public async Task<ServiceResult<PagedResult<QuestUpdateEntryDto>>> GetQuestUpdatesAsync(
 25        Guid questId,
 26        Guid userId,
 27        int skip = 0,
 28        int take = 20)
 29    {
 30        // Validate pagination parameters
 31        if (skip < 0)
 32        {
 33            return ServiceResult<PagedResult<QuestUpdateEntryDto>>.ValidationError("Skip must be non-negative");
 34        }
 35
 36        if (take < 1 || take > 100)
 37        {
 38            return ServiceResult<PagedResult<QuestUpdateEntryDto>>.ValidationError("Take must be between 1 and 100");
 39        }
 40
 41        // Find quest and check access
 42        var quest = await _context.Quests
 43            .AsNoTracking()
 44            .Include(q => q.Arc)
 45                .ThenInclude(a => a.Campaign)
 46            .FirstOrDefaultAsync(q => q.Id == questId);
 47
 48        if (quest == null)
 49        {
 50            return ServiceResult<PagedResult<QuestUpdateEntryDto>>.NotFound("Quest not found");
 51        }
 52
 53        // Check world membership
 54        var member = await _context.WorldMembers
 55            .AsNoTracking()
 56            .FirstOrDefaultAsync(m => m.WorldId == quest.Arc.Campaign.WorldId && m.UserId == userId);
 57
 58        if (member == null)
 59        {
 60            return ServiceResult<PagedResult<QuestUpdateEntryDto>>.NotFound("Quest not found");
 61        }
 62
 63        var isGM = member.Role == WorldRole.GM;
 64
 65        // Non-GM users cannot see GM-only quests
 66        if (quest.IsGmOnly && !isGM)
 67        {
 68            return ServiceResult<PagedResult<QuestUpdateEntryDto>>.NotFound("Quest not found");
 69        }
 70
 71        // Get total count
 72        var totalCount = await _context.QuestUpdates
 73            .CountAsync(qu => qu.QuestId == questId);
 74
 75        // Get paginated updates
 76        var updates = await _context.QuestUpdates
 77            .AsNoTracking()
 78            .Where(qu => qu.QuestId == questId)
 79            .OrderByDescending(qu => qu.CreatedAt)
 80            .Skip(skip)
 81            .Take(take)
 82            .Select(qu => new QuestUpdateEntryDto
 83            {
 84                Id = qu.Id,
 85                QuestId = qu.QuestId,
 86                Body = qu.Body,
 87                SessionId = qu.SessionId,
 88                SessionTitle = qu.Session != null ? qu.Session.Name : null,
 89                CreatedBy = qu.CreatedBy,
 90                CreatedByName = qu.Creator.DisplayName,
 91                CreatedByAvatarUrl = qu.Creator.AvatarUrl,
 92                CreatedAt = qu.CreatedAt
 93            })
 94            .ToListAsync();
 95
 96        var result = new PagedResult<QuestUpdateEntryDto>
 97        {
 98            Items = updates,
 99            TotalCount = totalCount,
 100            Skip = skip,
 101            Take = take
 102        };
 103
 104        return ServiceResult<PagedResult<QuestUpdateEntryDto>>.Success(result);
 105    }
 106
 107    public async Task<ServiceResult<QuestUpdateEntryDto>> CreateQuestUpdateAsync(
 108        Guid questId,
 109        QuestUpdateCreateDto dto,
 110        Guid userId)
 111    {
 112        // Validate input
 113        if (string.IsNullOrWhiteSpace(dto.Body))
 114        {
 115            return ServiceResult<QuestUpdateEntryDto>.ValidationError("Body is required and cannot be empty");
 116        }
 117
 118        // Find quest and check access
 119        var quest = await _context.Quests
 120            .Include(q => q.Arc)
 121                .ThenInclude(a => a.Campaign)
 122            .FirstOrDefaultAsync(q => q.Id == questId);
 123
 124        if (quest == null)
 125        {
 126            return ServiceResult<QuestUpdateEntryDto>.NotFound("Quest not found");
 127        }
 128
 129        // Check world membership and role
 130        var member = await _context.WorldMembers
 131            .AsNoTracking()
 132            .FirstOrDefaultAsync(m => m.WorldId == quest.Arc.Campaign.WorldId && m.UserId == userId);
 133
 134        if (member == null)
 135        {
 136            return ServiceResult<QuestUpdateEntryDto>.NotFound("Quest not found");
 137        }
 138
 139        // Observer cannot create updates
 140        if (member.Role == WorldRole.Observer)
 141        {
 142            return ServiceResult<QuestUpdateEntryDto>.Forbidden("Observers cannot create quest updates");
 143        }
 144
 145        var isGM = member.Role == WorldRole.GM;
 146
 147        // Non-GM users cannot update GM-only quests
 148        if (quest.IsGmOnly && !isGM)
 149        {
 150            return ServiceResult<QuestUpdateEntryDto>.NotFound("Quest not found");
 151        }
 152
 153        // Validate SessionId if provided (Session entity FK)
 154        Session? session = null;
 155        if (dto.SessionId.HasValue)
 156        {
 157            session = await _context.Sessions
 158                .AsNoTracking()
 159                .FirstOrDefaultAsync(s => s.Id == dto.SessionId.Value);
 160
 161            if (session == null)
 162            {
 163                return ServiceResult<QuestUpdateEntryDto>.ValidationError("Session not found");
 164            }
 165
 166            if (session.ArcId != quest.ArcId)
 167            {
 168                return ServiceResult<QuestUpdateEntryDto>.ValidationError("Session must belong to the same Arc as the qu
 169            }
 170        }
 171
 172        var now = DateTime.UtcNow;
 173
 174        var questUpdate = new QuestUpdate
 175        {
 176            Id = Guid.NewGuid(),
 177            QuestId = questId,
 178            SessionId = dto.SessionId,
 179            Body = dto.Body.Trim(),
 180            CreatedBy = userId,
 181            CreatedAt = now
 182        };
 183
 184        _context.QuestUpdates.Add(questUpdate);
 185
 186        // Update parent Quest's UpdatedAt timestamp
 187        quest.UpdatedAt = now;
 188
 189        await _context.SaveChangesAsync();
 190
 191        _logger.LogTraceSanitized("Created quest update for quest {QuestId} by user {UserId}", questId, userId);
 192
 193        // Fetch creator details for DTO
 194        var creator = await _context.Users.FindAsync(userId);
 195
 196        var resultDto = new QuestUpdateEntryDto
 197        {
 198            Id = questUpdate.Id,
 199            QuestId = questUpdate.QuestId,
 200            Body = questUpdate.Body,
 201            SessionId = questUpdate.SessionId,
 202            SessionTitle = session?.Name,
 203            CreatedBy = questUpdate.CreatedBy,
 204            CreatedByName = creator?.DisplayName ?? "Unknown",
 205            CreatedByAvatarUrl = creator?.AvatarUrl,
 206            CreatedAt = questUpdate.CreatedAt
 207        };
 208
 209        return ServiceResult<QuestUpdateEntryDto>.Success(resultDto);
 210    }
 211
 212    public async Task<ServiceResult<bool>> DeleteQuestUpdateAsync(
 213        Guid questId,
 214        Guid updateId,
 215        Guid userId)
 216    {
 217        // Find the quest update
 218        var questUpdate = await _context.QuestUpdates
 219            .Include(qu => qu.Quest)
 220                .ThenInclude(q => q.Arc)
 221                    .ThenInclude(a => a.Campaign)
 222            .FirstOrDefaultAsync(qu => qu.Id == updateId && qu.QuestId == questId);
 223
 224        if (questUpdate == null)
 225        {
 226            return ServiceResult<bool>.NotFound("Quest update not found");
 227        }
 228
 229        // Check world membership and role
 230        var member = await _context.WorldMembers
 231            .AsNoTracking()
 232            .FirstOrDefaultAsync(m => m.WorldId == questUpdate.Quest.Arc.Campaign.WorldId && m.UserId == userId);
 233
 234        if (member == null)
 235        {
 236            return ServiceResult<bool>.NotFound("Quest update not found");
 237        }
 238
 239        var isGM = member.Role == WorldRole.GM;
 240
 241        // Determine if user can delete this update
 242        bool canDelete = isGM || questUpdate.CreatedBy == userId;
 243
 244        if (!canDelete)
 245        {
 246            return ServiceResult<bool>.Forbidden("You can only delete your own quest updates");
 247        }
 248
 249        _context.QuestUpdates.Remove(questUpdate);
 250        await _context.SaveChangesAsync();
 251
 252        _logger.LogTraceSanitized("Deleted quest update {UpdateId} from quest {QuestId} by user {UserId}",
 253            updateId, questId, userId);
 254
 255        return ServiceResult<bool>.Success(true);
 256    }
 257}