< Summary

Information
Class: Chronicis.Api.Services.QuestService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/QuestService.cs
Line coverage
100%
Covered lines: 3
Uncovered lines: 0
Coverable lines: 3
Total lines: 407
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/QuestService.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 Quest operations with world membership authorization.
 12/// </summary>
 13public sealed class QuestService : IQuestService
 14{
 15    private readonly ChronicisDbContext _context;
 16    private readonly ILogger<QuestService> _logger;
 17
 18    public QuestService(ChronicisDbContext context, ILogger<QuestService> logger)
 19    {
 1820        _context = context;
 1821        _logger = logger;
 1822    }
 23
 24    public async Task<ServiceResult<List<QuestDto>>> GetQuestsByArcAsync(Guid arcId, Guid userId)
 25    {
 26        // Check if user has access to this arc via world membership
 27        var arc = await _context.Arcs
 28            .AsNoTracking()
 29            .Include(a => a.Campaign)
 30            .FirstOrDefaultAsync(a => a.Id == arcId);
 31
 32        if (arc == null)
 33        {
 34            return ServiceResult<List<QuestDto>>.NotFound("Arc not found");
 35        }
 36
 37        // Check world membership
 38        var member = await _context.WorldMembers
 39            .AsNoTracking()
 40            .FirstOrDefaultAsync(m => m.WorldId == arc.Campaign.WorldId && m.UserId == userId);
 41
 42        if (member == null)
 43        {
 44            // Don't disclose arc existence to non-members
 45            return ServiceResult<List<QuestDto>>.NotFound("Arc not found");
 46        }
 47
 48        var isGM = member.Role == WorldRole.GM;
 49
 50        // Build query - filter IsGmOnly for non-GM users
 51        var query = _context.Quests
 52            .AsNoTracking()
 53            .Where(q => q.ArcId == arcId);
 54
 55        if (!isGM)
 56        {
 57            query = query.Where(q => !q.IsGmOnly);
 58        }
 59
 60        var quests = await query
 61            .OrderBy(q => q.SortOrder)
 62            .ThenByDescending(q => q.UpdatedAt)
 63            .Select(q => new QuestDto
 64            {
 65                Id = q.Id,
 66                ArcId = q.ArcId,
 67                Title = q.Title,
 68                Description = q.Description,
 69                Status = q.Status,
 70                IsGmOnly = q.IsGmOnly,
 71                SortOrder = q.SortOrder,
 72                CreatedBy = q.CreatedBy,
 73                CreatedByName = q.Creator.DisplayName,
 74                CreatedAt = q.CreatedAt,
 75                UpdatedAt = q.UpdatedAt,
 76                RowVersion = Convert.ToBase64String(q.RowVersion),
 77                UpdateCount = q.Updates.Count
 78            })
 79            .ToListAsync();
 80
 81        _logger.LogTraceSanitized("Retrieved {Count} quests for arc {ArcId} for user {UserId} (GM: {IsGM})",
 82            quests.Count, arcId, userId, isGM);
 83
 84        return ServiceResult<List<QuestDto>>.Success(quests);
 85    }
 86
 87    public async Task<ServiceResult<QuestDto>> GetQuestAsync(Guid questId, Guid userId)
 88    {
 89        var quest = await _context.Quests
 90            .AsNoTracking()
 91            .Include(q => q.Arc)
 92                .ThenInclude(a => a.Campaign)
 93            .Include(q => q.Creator)
 94            .FirstOrDefaultAsync(q => q.Id == questId);
 95
 96        if (quest == null)
 97        {
 98            return ServiceResult<QuestDto>.NotFound("Quest not found");
 99        }
 100
 101        // Check world membership
 102        var member = await _context.WorldMembers
 103            .AsNoTracking()
 104            .FirstOrDefaultAsync(m => m.WorldId == quest.Arc.Campaign.WorldId && m.UserId == userId);
 105
 106        if (member == null)
 107        {
 108            // Don't disclose quest existence to non-members
 109            return ServiceResult<QuestDto>.NotFound("Quest not found");
 110        }
 111
 112        var isGM = member.Role == WorldRole.GM;
 113
 114        // Non-GM users cannot see GM-only quests
 115        if (quest.IsGmOnly && !isGM)
 116        {
 117            return ServiceResult<QuestDto>.NotFound("Quest not found");
 118        }
 119
 120        var updateCount = await _context.QuestUpdates
 121            .CountAsync(qu => qu.QuestId == questId);
 122
 123        var dto = new QuestDto
 124        {
 125            Id = quest.Id,
 126            ArcId = quest.ArcId,
 127            Title = quest.Title,
 128            Description = quest.Description,
 129            Status = quest.Status,
 130            IsGmOnly = quest.IsGmOnly,
 131            SortOrder = quest.SortOrder,
 132            CreatedBy = quest.CreatedBy,
 133            CreatedByName = quest.Creator.DisplayName,
 134            CreatedAt = quest.CreatedAt,
 135            UpdatedAt = quest.UpdatedAt,
 136            RowVersion = Convert.ToBase64String(quest.RowVersion),
 137            UpdateCount = updateCount
 138        };
 139
 140        return ServiceResult<QuestDto>.Success(dto);
 141    }
 142
 143    public async Task<ServiceResult<QuestDto>> CreateQuestAsync(Guid arcId, QuestCreateDto dto, Guid userId)
 144    {
 145        // Validate input
 146        if (string.IsNullOrWhiteSpace(dto.Title))
 147        {
 148            return ServiceResult<QuestDto>.ValidationError("Title is required");
 149        }
 150
 151        if (dto.Title.Length > 300)
 152        {
 153            return ServiceResult<QuestDto>.ValidationError("Title cannot exceed 300 characters");
 154        }
 155
 156        // Check if user has access to this arc and is GM
 157        var arc = await _context.Arcs
 158            .AsNoTracking()
 159            .Include(a => a.Campaign)
 160            .FirstOrDefaultAsync(a => a.Id == arcId);
 161
 162        if (arc == null)
 163        {
 164            return ServiceResult<QuestDto>.NotFound("Arc not found");
 165        }
 166
 167        // Check world membership and GM role
 168        var member = await _context.WorldMembers
 169            .AsNoTracking()
 170            .FirstOrDefaultAsync(m => m.WorldId == arc.Campaign.WorldId && m.UserId == userId);
 171
 172        if (member == null)
 173        {
 174            return ServiceResult<QuestDto>.NotFound("Arc not found");
 175        }
 176
 177        if (member.Role != WorldRole.GM)
 178        {
 179            return ServiceResult<QuestDto>.Forbidden("Only GMs can create quests");
 180        }
 181
 182        var now = DateTime.UtcNow;
 183        var quest = new Quest
 184        {
 185            Id = Guid.NewGuid(),
 186            ArcId = arcId,
 187            Title = dto.Title.Trim(),
 188            Description = dto.Description,
 189            Status = dto.Status ?? QuestStatus.Active,
 190            IsGmOnly = dto.IsGmOnly ?? false,
 191            SortOrder = dto.SortOrder ?? 0,
 192            CreatedBy = userId,
 193            CreatedAt = now,
 194            UpdatedAt = now
 195        };
 196
 197        _context.Quests.Add(quest);
 198        await _context.SaveChangesAsync();
 199
 200        _logger.LogTraceSanitized("Created quest '{Title}' in arc {ArcId} for user {UserId}",
 201            dto.Title, arcId, userId);
 202
 203        // Fetch creator name for DTO
 204        var creator = await _context.Users.FindAsync(userId);
 205
 206        var resultDto = new QuestDto
 207        {
 208            Id = quest.Id,
 209            ArcId = quest.ArcId,
 210            Title = quest.Title,
 211            Description = quest.Description,
 212            Status = quest.Status,
 213            IsGmOnly = quest.IsGmOnly,
 214            SortOrder = quest.SortOrder,
 215            CreatedBy = quest.CreatedBy,
 216            CreatedByName = creator?.DisplayName ?? "Unknown",
 217            CreatedAt = quest.CreatedAt,
 218            UpdatedAt = quest.UpdatedAt,
 219            RowVersion = Convert.ToBase64String(quest.RowVersion),
 220            UpdateCount = 0
 221        };
 222
 223        return ServiceResult<QuestDto>.Success(resultDto);
 224    }
 225
 226    public async Task<ServiceResult<QuestDto>> UpdateQuestAsync(Guid questId, QuestEditDto dto, Guid userId)
 227    {
 228        // Validate RowVersion is provided
 229        if (string.IsNullOrWhiteSpace(dto.RowVersion))
 230        {
 231            return ServiceResult<QuestDto>.ValidationError("RowVersion is required for updates");
 232        }
 233
 234        byte[] rowVersion;
 235        try
 236        {
 237            rowVersion = Convert.FromBase64String(dto.RowVersion);
 238        }
 239        catch (FormatException)
 240        {
 241            return ServiceResult<QuestDto>.ValidationError("Invalid RowVersion format");
 242        }
 243
 244        // Validate title if provided
 245        if (dto.Title != null && string.IsNullOrWhiteSpace(dto.Title))
 246        {
 247            return ServiceResult<QuestDto>.ValidationError("Title cannot be empty");
 248        }
 249
 250        if (dto.Title != null && dto.Title.Length > 300)
 251        {
 252            return ServiceResult<QuestDto>.ValidationError("Title cannot exceed 300 characters");
 253        }
 254
 255        // Find quest with tracking for update
 256        var quest = await _context.Quests
 257            .Include(q => q.Arc)
 258                .ThenInclude(a => a.Campaign)
 259            .Include(q => q.Creator)
 260            .FirstOrDefaultAsync(q => q.Id == questId);
 261
 262        if (quest == null)
 263        {
 264            return ServiceResult<QuestDto>.NotFound("Quest not found");
 265        }
 266
 267        // Check world membership and GM role
 268        var member = await _context.WorldMembers
 269            .AsNoTracking()
 270            .FirstOrDefaultAsync(m => m.WorldId == quest.Arc.Campaign.WorldId && m.UserId == userId);
 271
 272        if (member == null)
 273        {
 274            return ServiceResult<QuestDto>.NotFound("Quest not found");
 275        }
 276
 277        if (member.Role != WorldRole.GM)
 278        {
 279            return ServiceResult<QuestDto>.Forbidden("Only GMs can update quests");
 280        }
 281
 282        // Apply updates
 283        if (dto.Title != null)
 284        {
 285            quest.Title = dto.Title.Trim();
 286        }
 287
 288        if (dto.Description != null)
 289        {
 290            quest.Description = dto.Description;
 291        }
 292
 293        if (dto.Status.HasValue)
 294        {
 295            quest.Status = dto.Status.Value;
 296        }
 297
 298        if (dto.IsGmOnly.HasValue)
 299        {
 300            quest.IsGmOnly = dto.IsGmOnly.Value;
 301        }
 302
 303        if (dto.SortOrder.HasValue)
 304        {
 305            quest.SortOrder = dto.SortOrder.Value;
 306        }
 307
 308        quest.UpdatedAt = DateTime.UtcNow;
 309
 310        // Set original RowVersion for concurrency check
 311        _context.Entry(quest).Property(q => q.RowVersion).OriginalValue = rowVersion;
 312
 313        try
 314        {
 315            await _context.SaveChangesAsync();
 316
 317            _logger.LogTraceSanitized("Updated quest {QuestId} for user {UserId}", questId, userId);
 318        }
 319        catch (DbUpdateConcurrencyException)
 320        {
 321            // Reload current state from database
 322            await _context.Entry(quest).ReloadAsync();
 323
 324            var updateCount = await _context.QuestUpdates.CountAsync(qu => qu.QuestId == questId);
 325
 326            var currentDto = new QuestDto
 327            {
 328                Id = quest.Id,
 329                ArcId = quest.ArcId,
 330                Title = quest.Title,
 331                Description = quest.Description,
 332                Status = quest.Status,
 333                IsGmOnly = quest.IsGmOnly,
 334                SortOrder = quest.SortOrder,
 335                CreatedBy = quest.CreatedBy,
 336                CreatedByName = quest.Creator.DisplayName,
 337                CreatedAt = quest.CreatedAt,
 338                UpdatedAt = quest.UpdatedAt,
 339                RowVersion = Convert.ToBase64String(quest.RowVersion),
 340                UpdateCount = updateCount
 341            };
 342
 343            _logger.LogWarningSanitized("Concurrency conflict updating quest {QuestId}", questId);
 344
 345            return ServiceResult<QuestDto>.Conflict(
 346                "Quest was modified by another user. Please reload and try again.",
 347                currentDto);
 348        }
 349
 350        // Fetch update count for result
 351        var resultUpdateCount = await _context.QuestUpdates.CountAsync(qu => qu.QuestId == questId);
 352
 353        var resultDto = new QuestDto
 354        {
 355            Id = quest.Id,
 356            ArcId = quest.ArcId,
 357            Title = quest.Title,
 358            Description = quest.Description,
 359            Status = quest.Status,
 360            IsGmOnly = quest.IsGmOnly,
 361            SortOrder = quest.SortOrder,
 362            CreatedBy = quest.CreatedBy,
 363            CreatedByName = quest.Creator.DisplayName,
 364            CreatedAt = quest.CreatedAt,
 365            UpdatedAt = quest.UpdatedAt,
 366            RowVersion = Convert.ToBase64String(quest.RowVersion),
 367            UpdateCount = resultUpdateCount
 368        };
 369
 370        return ServiceResult<QuestDto>.Success(resultDto);
 371    }
 372
 373    public async Task<ServiceResult<bool>> DeleteQuestAsync(Guid questId, Guid userId)
 374    {
 375        var quest = await _context.Quests
 376            .Include(q => q.Arc)
 377                .ThenInclude(a => a.Campaign)
 378            .FirstOrDefaultAsync(q => q.Id == questId);
 379
 380        if (quest == null)
 381        {
 382            return ServiceResult<bool>.NotFound("Quest not found");
 383        }
 384
 385        // Check world membership and GM role
 386        var member = await _context.WorldMembers
 387            .AsNoTracking()
 388            .FirstOrDefaultAsync(m => m.WorldId == quest.Arc.Campaign.WorldId && m.UserId == userId);
 389
 390        if (member == null)
 391        {
 392            return ServiceResult<bool>.NotFound("Quest not found");
 393        }
 394
 395        if (member.Role != WorldRole.GM)
 396        {
 397            return ServiceResult<bool>.Forbidden("Only GMs can delete quests");
 398        }
 399
 400        _context.Quests.Remove(quest);
 401        await _context.SaveChangesAsync();
 402
 403        _logger.LogTraceSanitized("Deleted quest {QuestId} and its updates for user {UserId}", questId, userId);
 404
 405        return ServiceResult<bool>.Success(true);
 406    }
 407}