< Summary

Information
Class: Chronicis.Api.Services.SessionService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/SessionService.cs
Line coverage
100%
Covered lines: 25
Uncovered lines: 0
Coverable lines: 25
Total lines: 524
Line coverage: 100%
Branch coverage
100%
Covered branches: 6
Total branches: 6
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%
BuildDefaultNoteTitle(...)100%66100%
MapDto(...)100%11100%

File(s)

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

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Api.Models;
 3using Chronicis.Shared.DTOs;
 4using Chronicis.Shared.DTOs.Sessions;
 5using Chronicis.Shared.Enums;
 6using Chronicis.Shared.Models;
 7using Chronicis.Shared.Utilities;
 8using Microsoft.EntityFrameworkCore;
 9
 10namespace Chronicis.Api.Services;
 11
 12public sealed class SessionService : ISessionService
 13{
 14    private readonly ChronicisDbContext _context;
 15    private readonly ISummaryService _summaryService;
 16    private readonly IWorldDocumentService _worldDocumentService;
 17    private readonly ILogger<SessionService> _logger;
 18
 19    public SessionService(
 20        ChronicisDbContext context,
 21        ISummaryService summaryService,
 22        IWorldDocumentService worldDocumentService,
 23        ILogger<SessionService> logger)
 24    {
 725        _context = context;
 726        _summaryService = summaryService;
 727        _worldDocumentService = worldDocumentService;
 728        _logger = logger;
 729    }
 30
 31    public async Task<ServiceResult<List<SessionTreeDto>>> GetSessionsByArcAsync(Guid arcId, Guid userId)
 32    {
 33        var arc = await _context.Arcs
 34            .AsNoTracking()
 35            .Include(a => a.Campaign)
 36                .ThenInclude(c => c.World)
 37                    .ThenInclude(w => w.Members)
 38            .FirstOrDefaultAsync(a => a.Id == arcId);
 39
 40        if (arc == null)
 41        {
 42            return ServiceResult<List<SessionTreeDto>>.NotFound("Arc not found");
 43        }
 44
 45        var membership = arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 46        if (membership == null)
 47        {
 48            return ServiceResult<List<SessionTreeDto>>.NotFound("Arc not found or access denied");
 49        }
 50
 51        var sessions = await _context.Sessions
 52            .AsNoTracking()
 53            .Where(s => s.ArcId == arcId)
 54            .OrderBy(s => s.SessionDate ?? DateTime.MaxValue)
 55            .ThenBy(s => s.Name)
 56            .ThenBy(s => s.CreatedAt)
 57            .Select(s => new SessionTreeDto
 58            {
 59                Id = s.Id,
 60                ArcId = s.ArcId,
 61                Name = s.Name,
 62                SessionDate = s.SessionDate,
 63                HasAiSummary = !string.IsNullOrWhiteSpace(s.AiSummary)
 64            })
 65            .ToListAsync();
 66
 67        return ServiceResult<List<SessionTreeDto>>.Success(sessions);
 68    }
 69
 70    public async Task<ServiceResult<SessionDto>> GetSessionAsync(Guid sessionId, Guid userId)
 71    {
 72        var session = await _context.Sessions
 73            .AsNoTracking()
 74            .Include(s => s.Arc)
 75                .ThenInclude(a => a.Campaign)
 76                    .ThenInclude(c => c.World)
 77                        .ThenInclude(w => w.Members)
 78            .FirstOrDefaultAsync(s => s.Id == sessionId);
 79
 80        if (session == null)
 81        {
 82            return ServiceResult<SessionDto>.NotFound("Session not found");
 83        }
 84
 85        var membership = session.Arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 86        if (membership == null)
 87        {
 88            return ServiceResult<SessionDto>.NotFound("Session not found or access denied");
 89        }
 90
 91        var dto = MapDto(session);
 92
 93        var canViewPrivateNotes = membership.Role == WorldRole.GM
 94            || session.Arc.Campaign.World.OwnerId == userId;
 95
 96        // Server remains the source of truth for private notes visibility.
 97        if (!canViewPrivateNotes)
 98        {
 99            dto.PrivateNotes = null;
 100        }
 101
 102        return ServiceResult<SessionDto>.Success(dto);
 103    }
 104
 105    public async Task<ServiceResult<SessionDto>> CreateSessionAsync(Guid arcId, SessionCreateDto dto, Guid userId, strin
 106    {
 107        if (dto == null)
 108        {
 109            return ServiceResult<SessionDto>.ValidationError("Request body is required");
 110        }
 111
 112        if (string.IsNullOrWhiteSpace(dto.Name))
 113        {
 114            return ServiceResult<SessionDto>.ValidationError("Session name is required");
 115        }
 116
 117        var trimmedName = dto.Name.Trim();
 118        if (trimmedName.Length > 500)
 119        {
 120            return ServiceResult<SessionDto>.ValidationError("Session name must be 500 characters or fewer");
 121        }
 122
 123        var arc = await _context.Arcs
 124            .Include(a => a.Campaign)
 125                .ThenInclude(c => c.World)
 126                    .ThenInclude(w => w.Members)
 127            .FirstOrDefaultAsync(a => a.Id == arcId);
 128
 129        if (arc == null)
 130        {
 131            return ServiceResult<SessionDto>.NotFound("Arc not found");
 132        }
 133
 134        var membership = arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 135        if (membership == null)
 136        {
 137            return ServiceResult<SessionDto>.NotFound("Arc not found or access denied");
 138        }
 139
 140        if (membership.Role != WorldRole.GM)
 141        {
 142            return ServiceResult<SessionDto>.Forbidden("Only GMs can create sessions");
 143        }
 144
 145        var utcNow = DateTime.UtcNow;
 146        var session = new Session
 147        {
 148            Id = Guid.NewGuid(),
 149            ArcId = arc.Id,
 150            Name = trimmedName,
 151            SessionDate = dto.SessionDate,
 152            CreatedAt = utcNow,
 153            CreatedBy = userId
 154        };
 155
 156        var noteTitle = BuildDefaultNoteTitle(username);
 157        var noteSlug = await GenerateUniqueRootSlugAsync(noteTitle, arc.Campaign.WorldId);
 158
 159        var defaultNote = new Article
 160        {
 161            Id = Guid.NewGuid(),
 162            Title = noteTitle,
 163            Slug = noteSlug,
 164            Body = null,
 165            Type = ArticleType.SessionNote,
 166            Visibility = ArticleVisibility.Public,
 167            SessionId = session.Id,
 168            WorldId = arc.Campaign.WorldId,
 169            CampaignId = arc.CampaignId,
 170            ArcId = arc.Id,
 171            ParentId = null,
 172            CreatedBy = userId,
 173            CreatedAt = utcNow,
 174            EffectiveDate = utcNow
 175        };
 176
 177        _context.Sessions.Add(session);
 178        _context.Articles.Add(defaultNote);
 179        await _context.SaveChangesAsync();
 180
 181        _logger.LogTraceSanitized("Created session {SessionId} in arc {ArcId} with default note {NoteId}",
 182            session.Id, arc.Id, defaultNote.Id);
 183
 184        return ServiceResult<SessionDto>.Success(MapDto(session));
 185    }
 186
 187    public async Task<ServiceResult<SessionDto>> UpdateSessionNotesAsync(Guid sessionId, SessionUpdateDto dto, Guid user
 188    {
 189        if (dto == null)
 190        {
 191            return ServiceResult<SessionDto>.ValidationError("Request body is required");
 192        }
 193
 194        if (dto.ClearSessionDate && dto.SessionDate.HasValue)
 195        {
 196            return ServiceResult<SessionDto>.ValidationError("Session date and ClearSessionDate cannot both be set");
 197        }
 198
 199        string? trimmedName = null;
 200        if (dto.Name != null)
 201        {
 202            trimmedName = dto.Name.Trim();
 203            if (string.IsNullOrWhiteSpace(trimmedName))
 204            {
 205                return ServiceResult<SessionDto>.ValidationError("Session name is required");
 206            }
 207
 208            if (trimmedName.Length > 500)
 209            {
 210                return ServiceResult<SessionDto>.ValidationError("Session name must be 500 characters or fewer");
 211            }
 212        }
 213
 214        var session = await _context.Sessions
 215            .Include(s => s.Arc)
 216                .ThenInclude(a => a.Campaign)
 217                    .ThenInclude(c => c.World)
 218                        .ThenInclude(w => w.Members)
 219            .FirstOrDefaultAsync(s => s.Id == sessionId);
 220
 221        if (session == null)
 222        {
 223            return ServiceResult<SessionDto>.NotFound("Session not found");
 224        }
 225
 226        var membership = session.Arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 227        if (membership == null)
 228        {
 229            return ServiceResult<SessionDto>.NotFound("Session not found or access denied");
 230        }
 231
 232        var canEditSession = membership.Role == WorldRole.GM
 233            || session.Arc.Campaign.World.OwnerId == userId;
 234
 235        if (!canEditSession)
 236        {
 237            return ServiceResult<SessionDto>.Forbidden("Only the world owner or GMs can update session notes");
 238        }
 239
 240        if (trimmedName != null)
 241        {
 242            session.Name = trimmedName;
 243        }
 244
 245        if (dto.ClearSessionDate)
 246        {
 247            session.SessionDate = null;
 248        }
 249        else if (dto.SessionDate.HasValue)
 250        {
 251            session.SessionDate = dto.SessionDate;
 252        }
 253
 254        session.PublicNotes = dto.PublicNotes;
 255        session.PrivateNotes = dto.PrivateNotes;
 256        session.ModifiedAt = DateTime.UtcNow;
 257
 258        await _context.SaveChangesAsync();
 259
 260        _logger.LogTraceSanitized("Updated session {SessionId}", sessionId);
 261
 262        return ServiceResult<SessionDto>.Success(MapDto(session));
 263    }
 264
 265    public async Task<ServiceResult<bool>> DeleteSessionAsync(Guid sessionId, Guid userId)
 266    {
 267        var session = await _context.Sessions
 268            .Include(s => s.Arc)
 269                .ThenInclude(a => a.Campaign)
 270                    .ThenInclude(c => c.World)
 271                        .ThenInclude(w => w.Members)
 272            .FirstOrDefaultAsync(s => s.Id == sessionId);
 273
 274        if (session == null)
 275        {
 276            return ServiceResult<bool>.NotFound("Session not found");
 277        }
 278
 279        var membership = session.Arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 280        if (membership == null)
 281        {
 282            return ServiceResult<bool>.NotFound("Session not found or access denied");
 283        }
 284
 285        if (membership.Role != WorldRole.GM)
 286        {
 287            return ServiceResult<bool>.Forbidden("Only GMs can delete sessions");
 288        }
 289
 290        // QuestUpdate.SessionId uses NO ACTION, so session-linked updates must be removed first.
 291        var sessionQuestUpdates = await _context.QuestUpdates
 292            .Where(qu => qu.SessionId == sessionId)
 293            .ToListAsync();
 294
 295        if (sessionQuestUpdates.Count > 0)
 296        {
 297            _context.QuestUpdates.RemoveRange(sessionQuestUpdates);
 298            await _context.SaveChangesAsync();
 299        }
 300
 301        // Article.SessionId is SetNull, but product behavior expects session-linked notes to be deleted.
 302        // Delete root attached article trees (and descendants) explicitly to preserve article-delete cleanup.
 303        var attachedArticles = await _context.Articles
 304            .Where(a => a.SessionId == sessionId)
 305            .Select(a => new { a.Id, a.ParentId })
 306            .ToListAsync();
 307
 308        if (attachedArticles.Count > 0)
 309        {
 310            var attachedIds = attachedArticles.Select(a => a.Id).ToHashSet();
 311            var rootAttachedArticleIds = attachedArticles
 312                .Where(a => !a.ParentId.HasValue || !attachedIds.Contains(a.ParentId.Value))
 313                .Select(a => a.Id)
 314                .ToList();
 315
 316            foreach (var articleId in rootAttachedArticleIds)
 317            {
 318                await DeleteArticleAndDescendantsAsync(articleId);
 319            }
 320        }
 321
 322        _context.Sessions.Remove(session);
 323        await _context.SaveChangesAsync();
 324
 325        _logger.LogTraceSanitized(
 326            "Deleted session {SessionId} with {QuestUpdateCount} quest updates and {AttachedArticleCount} attached sessi
 327            sessionId,
 328            sessionQuestUpdates.Count,
 329            attachedArticles.Count);
 330
 331        return ServiceResult<bool>.Success(true);
 332    }
 333
 334    public async Task<ServiceResult<SummaryGenerationDto>> GenerateAiSummaryAsync(Guid sessionId, Guid userId)
 335    {
 336        var session = await _context.Sessions
 337            .Include(s => s.Arc)
 338                .ThenInclude(a => a.Campaign)
 339                    .ThenInclude(c => c.World)
 340                        .ThenInclude(w => w.Members)
 341            .FirstOrDefaultAsync(s => s.Id == sessionId);
 342
 343        if (session == null)
 344        {
 345            return ServiceResult<SummaryGenerationDto>.NotFound("Session not found");
 346        }
 347
 348        var membership = session.Arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 349        if (membership == null)
 350        {
 351            return ServiceResult<SummaryGenerationDto>.NotFound("Session not found or access denied");
 352        }
 353
 354        var summarySources = new List<SummarySourceDto>();
 355        var sourceBlocks = new List<string>();
 356
 357        if (!string.IsNullOrWhiteSpace(session.PublicNotes))
 358        {
 359            sourceBlocks.Add($"--- From: {session.Name} (Public Notes) ---\n{session.PublicNotes}\n---");
 360            summarySources.Add(new SummarySourceDto
 361            {
 362                Type = "SessionPublicNotes",
 363                Title = $"{session.Name} (Public Notes)"
 364            });
 365        }
 366
 367        // Security rule: source filtering is fixed and caller-independent. Only Public SessionNote bodies are allowed.
 368        var publicSessionNotes = await _context.Articles
 369            .AsNoTracking()
 370            .Where(a => a.SessionId == sessionId
 371                && a.Type == ArticleType.SessionNote
 372                && a.Visibility == ArticleVisibility.Public
 373                && !string.IsNullOrEmpty(a.Body))
 374            .OrderBy(a => a.CreatedAt)
 375            .Select(a => new
 376            {
 377                a.Id,
 378                a.Title,
 379                a.Body
 380            })
 381            .ToListAsync();
 382
 383        foreach (var note in publicSessionNotes)
 384        {
 385            sourceBlocks.Add($"--- From: {note.Title} (SessionNote) ---\n{note.Body}\n---");
 386            summarySources.Add(new SummarySourceDto
 387            {
 388                Type = "SessionNote",
 389                Title = note.Title ?? "Session Note",
 390                ArticleId = note.Id
 391            });
 392        }
 393
 394        if (sourceBlocks.Count == 0)
 395        {
 396            return ServiceResult<SummaryGenerationDto>.ValidationError("No public content available for this session.");
 397        }
 398
 399        var sourceContent = string.Join("\n\n", sourceBlocks);
 400        var generation = await _summaryService.GenerateSessionSummaryFromSourcesAsync(
 401            session.Name,
 402            sourceContent,
 403            summarySources);
 404
 405        if (!generation.Success)
 406        {
 407            return ServiceResult<SummaryGenerationDto>.ValidationError(
 408                generation.ErrorMessage ?? "Error generating session summary");
 409        }
 410
 411        var generatedAt = DateTime.UtcNow;
 412        session.AiSummary = generation.Summary;
 413        session.AiSummaryGeneratedAt = generatedAt;
 414        session.AiSummaryGeneratedByUserId = userId;
 415
 416        await _context.SaveChangesAsync();
 417
 418        generation.GeneratedDate = generatedAt;
 419
 420        _logger.LogTraceSanitized("Generated AI summary for session {SessionId}", sessionId);
 421
 422        return ServiceResult<SummaryGenerationDto>.Success(generation);
 423    }
 424
 425    public async Task<ServiceResult<bool>> ClearAiSummaryAsync(Guid sessionId, Guid userId)
 426    {
 427        var session = await _context.Sessions
 428            .Include(s => s.Arc)
 429                .ThenInclude(a => a.Campaign)
 430                    .ThenInclude(c => c.World)
 431                        .ThenInclude(w => w.Members)
 432            .FirstOrDefaultAsync(s => s.Id == sessionId);
 433
 434        if (session == null)
 435        {
 436            return ServiceResult<bool>.NotFound("Session not found");
 437        }
 438
 439        var membership = session.Arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 440        if (membership == null)
 441        {
 442            return ServiceResult<bool>.NotFound("Session not found or access denied");
 443        }
 444
 445        session.AiSummary = null;
 446        session.AiSummaryGeneratedAt = null;
 447        session.AiSummaryGeneratedByUserId = null;
 448
 449        await _context.SaveChangesAsync();
 450
 451        _logger.LogTraceSanitized("Cleared AI summary for session {SessionId}", sessionId);
 452
 453        return ServiceResult<bool>.Success(true);
 454    }
 455
 456    private async Task DeleteArticleAndDescendantsAsync(Guid articleId)
 457    {
 458        var childIds = await _context.Articles
 459            .Where(a => a.ParentId == articleId)
 460            .Select(a => a.Id)
 461            .ToListAsync();
 462
 463        foreach (var childId in childIds)
 464        {
 465            await DeleteArticleAndDescendantsAsync(childId);
 466        }
 467
 468        var linksToDelete = await _context.ArticleLinks
 469            .Where(l => l.SourceArticleId == articleId || l.TargetArticleId == articleId)
 470            .ToListAsync();
 471        _context.ArticleLinks.RemoveRange(linksToDelete);
 472
 473        await _worldDocumentService.DeleteArticleImagesAsync(articleId);
 474
 475        var article = await _context.Articles.FindAsync(articleId);
 476        if (article != null)
 477        {
 478            _context.Articles.Remove(article);
 479        }
 480
 481        await _context.SaveChangesAsync();
 482    }
 483
 484    private async Task<string> GenerateUniqueRootSlugAsync(string title, Guid worldId)
 485    {
 486        var baseSlug = SlugGenerator.GenerateSlug(title);
 487        var existingSlugs = await _context.Articles
 488            .AsNoTracking()
 489            .Where(a => a.WorldId == worldId && a.ParentId == null)
 490            .Select(a => a.Slug)
 491            .ToHashSetAsync();
 492
 493        return SlugGenerator.GenerateUniqueSlug(baseSlug, existingSlugs);
 494    }
 495
 496    private static string BuildDefaultNoteTitle(string? username)
 497    {
 5498        var trimmed = username?.Trim();
 5499        var title = string.IsNullOrWhiteSpace(trimmed)
 5500            ? "My Notes"
 5501            : $"{trimmed}'s Notes";
 502
 5503        return title.Length <= 500 ? title : title[..500];
 504    }
 505
 506    private static SessionDto MapDto(Session session)
 507    {
 4508        return new SessionDto
 4509        {
 4510            Id = session.Id,
 4511            ArcId = session.ArcId,
 4512            Name = session.Name,
 4513            SessionDate = session.SessionDate,
 4514            PublicNotes = session.PublicNotes,
 4515            PrivateNotes = session.PrivateNotes,
 4516            AiSummary = session.AiSummary,
 4517            AiSummaryGeneratedAt = session.AiSummaryGeneratedAt,
 4518            AiSummaryGeneratedByUserId = session.AiSummaryGeneratedByUserId,
 4519            CreatedAt = session.CreatedAt,
 4520            ModifiedAt = session.ModifiedAt,
 4521            CreatedBy = session.CreatedBy
 4522        };
 523    }
 524}