< 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: 30
Uncovered lines: 0
Coverable lines: 30
Total lines: 609
Line coverage: 100%
Branch coverage
100%
Covered branches: 24
Total branches: 24
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%1818100%

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 IReservedSlugProvider _reservedSlugProvider;
 18    private readonly ILogger<SessionService> _logger;
 19
 20    public SessionService(
 21        ChronicisDbContext context,
 22        ISummaryService summaryService,
 23        IWorldDocumentService worldDocumentService,
 24        IReservedSlugProvider reservedSlugProvider,
 25        ILogger<SessionService> logger)
 26    {
 1627        _context = context;
 1628        _summaryService = summaryService;
 1629        _worldDocumentService = worldDocumentService;
 1630        _reservedSlugProvider = reservedSlugProvider;
 1631        _logger = logger;
 1632    }
 33
 34    public async Task<ServiceResult<List<SessionTreeDto>>> GetSessionsByArcAsync(Guid arcId, Guid userId)
 35    {
 36        var arc = await _context.Arcs
 37            .AsNoTracking()
 38            .Include(a => a.Campaign)
 39                .ThenInclude(c => c.World)
 40                    .ThenInclude(w => w.Members)
 41            .FirstOrDefaultAsync(a => a.Id == arcId);
 42
 43        if (arc == null)
 44        {
 45            return ServiceResult<List<SessionTreeDto>>.NotFound("Arc not found");
 46        }
 47
 48        var membership = arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 49        if (membership == null)
 50        {
 51            return ServiceResult<List<SessionTreeDto>>.NotFound("Arc not found or access denied");
 52        }
 53
 54        var sessions = await _context.Sessions
 55            .AsNoTracking()
 56            .Where(s => s.ArcId == arcId)
 57            .OrderBy(s => s.SessionDate ?? DateTime.MaxValue)
 58            .ThenBy(s => s.Name)
 59            .ThenBy(s => s.CreatedAt)
 60            .Select(s => new SessionTreeDto
 61            {
 62                Id = s.Id,
 63                ArcId = s.ArcId,
 64                Name = s.Name,
 65                SessionDate = s.SessionDate,
 66                HasAiSummary = !string.IsNullOrWhiteSpace(s.AiSummary),
 67                Slug = s.Slug,
 68                ArcSlug = s.Arc.Slug,
 69                CampaignSlug = s.Arc.Campaign.Slug,
 70                WorldSlug = s.Arc.Campaign.World.Slug
 71            })
 72            .ToListAsync();
 73
 74        return ServiceResult<List<SessionTreeDto>>.Success(sessions);
 75    }
 76
 77    public async Task<ServiceResult<SessionDto>> GetSessionAsync(Guid sessionId, Guid userId)
 78    {
 79        var session = await _context.Sessions
 80            .AsNoTracking()
 81            .Include(s => s.Arc)
 82                .ThenInclude(a => a.Campaign)
 83                    .ThenInclude(c => c.World)
 84                        .ThenInclude(w => w.Members)
 85            .FirstOrDefaultAsync(s => s.Id == sessionId);
 86
 87        if (session == null)
 88        {
 89            return ServiceResult<SessionDto>.NotFound("Session not found");
 90        }
 91
 92        var membership = session.Arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 93        if (membership == null)
 94        {
 95            return ServiceResult<SessionDto>.NotFound("Session not found or access denied");
 96        }
 97
 98        var dto = MapDto(session);
 99
 100        var canViewPrivateNotes = membership.Role == WorldRole.GM
 101            || session.Arc.Campaign.World.OwnerId == userId;
 102
 103        // Server remains the source of truth for private notes visibility.
 104        if (!canViewPrivateNotes)
 105        {
 106            dto.PrivateNotes = null;
 107        }
 108
 109        return ServiceResult<SessionDto>.Success(dto);
 110    }
 111
 112    public async Task<ServiceResult<SessionDto>> CreateSessionAsync(Guid arcId, SessionCreateDto dto, Guid userId, strin
 113    {
 114        if (dto == null)
 115        {
 116            return ServiceResult<SessionDto>.ValidationError("Request body is required");
 117        }
 118
 119        if (string.IsNullOrWhiteSpace(dto.Name))
 120        {
 121            return ServiceResult<SessionDto>.ValidationError("Session name is required");
 122        }
 123
 124        var trimmedName = dto.Name.Trim();
 125        if (trimmedName.Length > 500)
 126        {
 127            return ServiceResult<SessionDto>.ValidationError("Session name must be 500 characters or fewer");
 128        }
 129
 130        var arc = await _context.Arcs
 131            .Include(a => a.Campaign)
 132                .ThenInclude(c => c.World)
 133                    .ThenInclude(w => w.Members)
 134            .FirstOrDefaultAsync(a => a.Id == arcId);
 135
 136        if (arc == null)
 137        {
 138            return ServiceResult<SessionDto>.NotFound("Arc not found");
 139        }
 140
 141        var membership = arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 142        if (membership == null)
 143        {
 144            return ServiceResult<SessionDto>.NotFound("Arc not found or access denied");
 145        }
 146
 147        if (membership.Role != WorldRole.GM)
 148        {
 149            return ServiceResult<SessionDto>.Forbidden("Only GMs can create sessions");
 150        }
 151
 152        var utcNow = DateTime.UtcNow;
 153        var sessionSlugBase = !string.IsNullOrWhiteSpace(dto.Slug) && SlugGenerator.IsValidSlug(dto.Slug.Trim())
 154            ? dto.Slug.Trim()
 155            : SlugGenerator.GenerateSlug(trimmedName);
 156        var existingSessionSlugs = await _context.Sessions.AsNoTracking()
 157            .Where(s => s.ArcId == arc.Id)
 158            .Select(s => s.Slug)
 159            .ToHashSetAsync();
 160        var sessionSlug = SlugGenerator.GenerateUniqueSiblingSlug(sessionSlugBase, existingSessionSlugs, _reservedSlugPr
 161        var session = new Session
 162        {
 163            Id = Guid.NewGuid(),
 164            ArcId = arc.Id,
 165            Name = trimmedName,
 166            Slug = sessionSlug,
 167            SessionDate = dto.SessionDate,
 168            CreatedAt = utcNow,
 169            CreatedBy = userId
 170        };
 171
 172        var noteTitle = BuildDefaultNoteTitle(username);
 173        var noteSlug = await GenerateSessionNoteSlugAsync(noteTitle, session.Id);
 174
 175        var defaultNote = new Article
 176        {
 177            Id = Guid.NewGuid(),
 178            Title = noteTitle,
 179            Slug = noteSlug,
 180            Body = null,
 181            Type = ArticleType.SessionNote,
 182            Visibility = ArticleVisibility.Public,
 183            SessionId = session.Id,
 184            WorldId = arc.Campaign.WorldId,
 185            CampaignId = arc.CampaignId,
 186            ArcId = arc.Id,
 187            ParentId = null,
 188            CreatedBy = userId,
 189            CreatedAt = utcNow,
 190            EffectiveDate = utcNow
 191        };
 192
 193        _context.Sessions.Add(session);
 194        _context.Articles.Add(defaultNote);
 195        await _context.SaveChangesAsync();
 196
 197        _logger.LogTraceSanitized("Created session {SessionId} in arc {ArcId} with default note {NoteId}",
 198            session.Id, arc.Id, defaultNote.Id);
 199
 200        session.Arc = arc;
 201        return ServiceResult<SessionDto>.Success(MapDto(session));
 202    }
 203
 204    public async Task<ServiceResult<SessionDto>> UpdateSessionNotesAsync(Guid sessionId, SessionUpdateDto dto, Guid user
 205    {
 206        if (dto == null)
 207        {
 208            return ServiceResult<SessionDto>.ValidationError("Request body is required");
 209        }
 210
 211        if (dto.ClearSessionDate && dto.SessionDate.HasValue)
 212        {
 213            return ServiceResult<SessionDto>.ValidationError("Session date and ClearSessionDate cannot both be set");
 214        }
 215
 216        string? trimmedName = null;
 217        if (dto.Name != null)
 218        {
 219            trimmedName = dto.Name.Trim();
 220            if (string.IsNullOrWhiteSpace(trimmedName))
 221            {
 222                return ServiceResult<SessionDto>.ValidationError("Session name is required");
 223            }
 224
 225            if (trimmedName.Length > 500)
 226            {
 227                return ServiceResult<SessionDto>.ValidationError("Session name must be 500 characters or fewer");
 228            }
 229        }
 230
 231        var session = await _context.Sessions
 232            .Include(s => s.Arc)
 233                .ThenInclude(a => a.Campaign)
 234                    .ThenInclude(c => c.World)
 235                        .ThenInclude(w => w.Members)
 236            .FirstOrDefaultAsync(s => s.Id == sessionId);
 237
 238        if (session == null)
 239        {
 240            return ServiceResult<SessionDto>.NotFound("Session not found");
 241        }
 242
 243        var membership = session.Arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 244        if (membership == null)
 245        {
 246            return ServiceResult<SessionDto>.NotFound("Session not found or access denied");
 247        }
 248
 249        var canEditSession = membership.Role == WorldRole.GM
 250            || session.Arc.Campaign.World.OwnerId == userId;
 251
 252        if (!canEditSession)
 253        {
 254            return ServiceResult<SessionDto>.Forbidden("Only the world owner or GMs can update session notes");
 255        }
 256
 257        if (trimmedName != null)
 258        {
 259            if (session.Name != trimmedName)
 260            {
 261                session.Slug = await GenerateUniqueSessionSlugAsync(trimmedName, session.ArcId, session.Id);
 262            }
 263
 264            session.Name = trimmedName;
 265        }
 266
 267        if (dto.ClearSessionDate)
 268        {
 269            session.SessionDate = null;
 270        }
 271        else if (dto.SessionDate.HasValue)
 272        {
 273            session.SessionDate = dto.SessionDate;
 274        }
 275
 276        session.PublicNotes = dto.PublicNotes;
 277        session.PrivateNotes = dto.PrivateNotes;
 278        session.ModifiedAt = DateTime.UtcNow;
 279
 280        await _context.SaveChangesAsync();
 281
 282        _logger.LogTraceSanitized("Updated session {SessionId}", sessionId);
 283
 284        return ServiceResult<SessionDto>.Success(MapDto(session));
 285    }
 286
 287    public async Task<ServiceResult<bool>> DeleteSessionAsync(Guid sessionId, Guid userId)
 288    {
 289        var session = await _context.Sessions
 290            .Include(s => s.Arc)
 291                .ThenInclude(a => a.Campaign)
 292                    .ThenInclude(c => c.World)
 293                        .ThenInclude(w => w.Members)
 294            .FirstOrDefaultAsync(s => s.Id == sessionId);
 295
 296        if (session == null)
 297        {
 298            return ServiceResult<bool>.NotFound("Session not found");
 299        }
 300
 301        var membership = session.Arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 302        if (membership == null)
 303        {
 304            return ServiceResult<bool>.NotFound("Session not found or access denied");
 305        }
 306
 307        if (membership.Role != WorldRole.GM)
 308        {
 309            return ServiceResult<bool>.Forbidden("Only GMs can delete sessions");
 310        }
 311
 312        // QuestUpdate.SessionId uses NO ACTION, so session-linked updates must be removed first.
 313        var sessionQuestUpdates = await _context.QuestUpdates
 314            .Where(qu => qu.SessionId == sessionId)
 315            .ToListAsync();
 316
 317        if (sessionQuestUpdates.Count > 0)
 318        {
 319            _context.QuestUpdates.RemoveRange(sessionQuestUpdates);
 320            await _context.SaveChangesAsync();
 321        }
 322
 323        // Article.SessionId is SetNull, but product behavior expects session-linked notes to be deleted.
 324        // Delete root attached article trees (and descendants) explicitly to preserve article-delete cleanup.
 325        var attachedArticles = await _context.Articles
 326            .Where(a => a.SessionId == sessionId)
 327            .Select(a => new { a.Id, a.ParentId })
 328            .ToListAsync();
 329
 330        if (attachedArticles.Count > 0)
 331        {
 332            var attachedIds = attachedArticles.Select(a => a.Id).ToHashSet();
 333            var rootAttachedArticleIds = attachedArticles
 334                .Where(a => !a.ParentId.HasValue || !attachedIds.Contains(a.ParentId.Value))
 335                .Select(a => a.Id)
 336                .ToList();
 337
 338            foreach (var articleId in rootAttachedArticleIds)
 339            {
 340                await DeleteArticleAndDescendantsAsync(articleId);
 341            }
 342        }
 343
 344        _context.Sessions.Remove(session);
 345        await _context.SaveChangesAsync();
 346
 347        _logger.LogTraceSanitized(
 348            "Deleted session {SessionId} with {QuestUpdateCount} quest updates and {AttachedArticleCount} attached sessi
 349            sessionId,
 350            sessionQuestUpdates.Count,
 351            attachedArticles.Count);
 352
 353        return ServiceResult<bool>.Success(true);
 354    }
 355
 356    public async Task<ServiceResult<SummaryGenerationDto>> GenerateAiSummaryAsync(Guid sessionId, Guid userId)
 357    {
 358        var session = await _context.Sessions
 359            .Include(s => s.Arc)
 360                .ThenInclude(a => a.Campaign)
 361                    .ThenInclude(c => c.World)
 362                        .ThenInclude(w => w.Members)
 363            .FirstOrDefaultAsync(s => s.Id == sessionId);
 364
 365        if (session == null)
 366        {
 367            return ServiceResult<SummaryGenerationDto>.NotFound("Session not found");
 368        }
 369
 370        var membership = session.Arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 371        if (membership == null)
 372        {
 373            return ServiceResult<SummaryGenerationDto>.NotFound("Session not found or access denied");
 374        }
 375
 376        var summarySources = new List<SummarySourceDto>();
 377        var sourceBlocks = new List<string>();
 378
 379        if (!string.IsNullOrWhiteSpace(session.PublicNotes))
 380        {
 381            sourceBlocks.Add($"--- From: {session.Name} (Public Notes) ---\n{session.PublicNotes}\n---");
 382            summarySources.Add(new SummarySourceDto
 383            {
 384                Type = "SessionPublicNotes",
 385                Title = $"{session.Name} (Public Notes)"
 386            });
 387        }
 388
 389        // Security rule: source filtering is fixed and caller-independent. Only Public SessionNote bodies are allowed.
 390        var publicSessionNotes = await _context.Articles
 391            .AsNoTracking()
 392            .Where(a => a.SessionId == sessionId
 393                && a.Type == ArticleType.SessionNote
 394                && a.Visibility == ArticleVisibility.Public
 395                && !string.IsNullOrEmpty(a.Body))
 396            .OrderBy(a => a.CreatedAt)
 397            .Select(a => new
 398            {
 399                a.Id,
 400                a.Title,
 401                a.Body
 402            })
 403            .ToListAsync();
 404
 405        foreach (var note in publicSessionNotes)
 406        {
 407            sourceBlocks.Add($"--- From: {note.Title} (SessionNote) ---\n{note.Body}\n---");
 408            summarySources.Add(new SummarySourceDto
 409            {
 410                Type = "SessionNote",
 411                Title = note.Title ?? "Session Note",
 412                ArticleId = note.Id
 413            });
 414        }
 415
 416        if (sourceBlocks.Count == 0)
 417        {
 418            return ServiceResult<SummaryGenerationDto>.ValidationError("No public content available for this session.");
 419        }
 420
 421        var sourceContent = string.Join("\n\n", sourceBlocks);
 422        var generation = await _summaryService.GenerateSessionSummaryFromSourcesAsync(
 423            session.Name,
 424            sourceContent,
 425            summarySources);
 426
 427        if (!generation.Success)
 428        {
 429            return ServiceResult<SummaryGenerationDto>.ValidationError(
 430                generation.ErrorMessage ?? "Error generating session summary");
 431        }
 432
 433        var generatedAt = DateTime.UtcNow;
 434        session.AiSummary = generation.Summary;
 435        session.AiSummaryGeneratedAt = generatedAt;
 436        session.AiSummaryGeneratedByUserId = userId;
 437
 438        await _context.SaveChangesAsync();
 439
 440        generation.GeneratedDate = generatedAt;
 441
 442        _logger.LogTraceSanitized("Generated AI summary for session {SessionId}", sessionId);
 443
 444        return ServiceResult<SummaryGenerationDto>.Success(generation);
 445    }
 446
 447    public async Task<ServiceResult<bool>> ClearAiSummaryAsync(Guid sessionId, Guid userId)
 448    {
 449        var session = await _context.Sessions
 450            .Include(s => s.Arc)
 451                .ThenInclude(a => a.Campaign)
 452                    .ThenInclude(c => c.World)
 453                        .ThenInclude(w => w.Members)
 454            .FirstOrDefaultAsync(s => s.Id == sessionId);
 455
 456        if (session == null)
 457        {
 458            return ServiceResult<bool>.NotFound("Session not found");
 459        }
 460
 461        var membership = session.Arc.Campaign.World.Members.FirstOrDefault(m => m.UserId == userId);
 462        if (membership == null)
 463        {
 464            return ServiceResult<bool>.NotFound("Session not found or access denied");
 465        }
 466
 467        session.AiSummary = null;
 468        session.AiSummaryGeneratedAt = null;
 469        session.AiSummaryGeneratedByUserId = null;
 470
 471        await _context.SaveChangesAsync();
 472
 473        _logger.LogTraceSanitized("Cleared AI summary for session {SessionId}", sessionId);
 474
 475        return ServiceResult<bool>.Success(true);
 476    }
 477
 478    private async Task DeleteArticleAndDescendantsAsync(Guid articleId)
 479    {
 480        var childIds = await _context.Articles
 481            .Where(a => a.ParentId == articleId)
 482            .Select(a => a.Id)
 483            .ToListAsync();
 484
 485        foreach (var childId in childIds)
 486        {
 487            await DeleteArticleAndDescendantsAsync(childId);
 488        }
 489
 490        var linksToDelete = await _context.ArticleLinks
 491            .Where(l => l.SourceArticleId == articleId || l.TargetArticleId == articleId)
 492            .ToListAsync();
 493        _context.ArticleLinks.RemoveRange(linksToDelete);
 494
 495        await _worldDocumentService.DeleteArticleImagesAsync(articleId);
 496
 497        var article = await _context.Articles.FindAsync(articleId);
 498        if (article != null)
 499        {
 500            _context.Articles.Remove(article);
 501        }
 502
 503        await _context.SaveChangesAsync();
 504    }
 505
 506    private async Task<string> GenerateSessionNoteSlugAsync(string title, Guid sessionId)
 507    {
 508        var baseSlug = SlugGenerator.GenerateSlug(title);
 509        var existingSlugs = await _context.Articles
 510            .AsNoTracking()
 511            .Where(a => a.SessionId == sessionId &&
 512                        a.Type == ArticleType.SessionNote &&
 513                        a.ParentId == null)
 514            .Select(a => a.Slug)
 515            .ToHashSetAsync();
 516
 517        return SlugGenerator.GenerateUniqueSlug(baseSlug, existingSlugs);
 518    }
 519
 520    private static string BuildDefaultNoteTitle(string? username)
 521    {
 5522        var trimmed = username?.Trim();
 5523        var title = string.IsNullOrWhiteSpace(trimmed)
 5524            ? "My Notes"
 5525            : $"{trimmed}'s Notes";
 526
 5527        return title.Length <= 500 ? title : title[..500];
 528    }
 529
 530    public async Task<(Guid Id, string Name)?> GetIdBySlugAsync(Guid arcId, string slug)
 531    {
 532        var row = await _context.Sessions.AsNoTracking()
 533            .Where(s => s.ArcId == arcId && s.Slug == slug)
 534            .Select(s => new { s.Id, s.Name })
 535            .FirstOrDefaultAsync();
 536
 537        return row == null ? null : (row.Id, row.Name);
 538    }
 539
 540    public async Task<ServiceResult<string>> UpdateSlugAsync(Guid sessionId, string slug, Guid userId)
 541    {
 542        var session = await _context.Sessions.AsNoTracking()
 543            .FirstOrDefaultAsync(s => s.Id == sessionId);
 544
 545        if (session == null)
 546            return ServiceResult<string>.NotFound("Session not found");
 547
 548        var isGm = await _context.Arcs
 549            .AnyAsync(a => a.Id == session.ArcId
 550                && (a.Campaign.World.OwnerId == userId
 551                    || a.Campaign.World.Members.Any(m => m.UserId == userId && m.Role == WorldRole.GM)));
 552
 553        if (!isGm)
 554            return ServiceResult<string>.Forbidden("Only the world owner or GM may update the slug");
 555
 556        if (!SlugGenerator.IsValidSlug(slug))
 557            return ServiceResult<string>.ValidationError("SLUG_INVALID");
 558
 559        if (_reservedSlugProvider.IsReserved(slug))
 560            return ServiceResult<string>.ValidationError("SLUG_RESERVED");
 561
 562        var existing = await _context.Sessions.AsNoTracking()
 563            .Where(s => s.ArcId == session.ArcId && s.Id != sessionId)
 564            .Select(s => s.Slug)
 565            .ToHashSetAsync();
 566
 567        var finalSlug = SlugGenerator.GenerateUniqueSiblingSlug(slug, existing, _reservedSlugProvider.All);
 568
 569        var tracked = await _context.Sessions.FirstAsync(s => s.Id == sessionId);
 570        tracked.Slug = finalSlug;
 571        await _context.SaveChangesAsync();
 572
 573        return ServiceResult<string>.Success(finalSlug);
 574    }
 575
 576    private async Task<string> GenerateUniqueSessionSlugAsync(string name, Guid arcId, Guid? excludeId = null)
 577    {
 578        var existing = await _context.Sessions.AsNoTracking()
 579            .Where(s => s.ArcId == arcId && (!excludeId.HasValue || s.Id != excludeId.Value))
 580            .Select(s => s.Slug)
 581            .ToHashSetAsync();
 582
 583        return SlugGenerator.GenerateUniqueSiblingSlug(
 584            SlugGenerator.GenerateSlug(name), existing, _reservedSlugProvider.All);
 585    }
 586
 587    private static SessionDto MapDto(Session session)
 588    {
 7589        return new SessionDto
 7590        {
 7591            Id = session.Id,
 7592            ArcId = session.ArcId,
 7593            Name = session.Name,
 7594            SessionDate = session.SessionDate,
 7595            PublicNotes = session.PublicNotes,
 7596            PrivateNotes = session.PrivateNotes,
 7597            AiSummary = session.AiSummary,
 7598            AiSummaryGeneratedAt = session.AiSummaryGeneratedAt,
 7599            AiSummaryGeneratedByUserId = session.AiSummaryGeneratedByUserId,
 7600            CreatedAt = session.CreatedAt,
 7601            ModifiedAt = session.ModifiedAt,
 7602            CreatedBy = session.CreatedBy,
 7603            Slug = session.Slug,
 7604            ArcSlug = session.Arc?.Slug ?? string.Empty,
 7605            CampaignSlug = session.Arc?.Campaign?.Slug ?? string.Empty,
 7606            WorldSlug = session.Arc?.Campaign?.World?.Slug ?? string.Empty
 7607        };
 608    }
 609}