< Summary

Information
Class: Chronicis.Client.ViewModels.SessionDetailViewModel
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/ViewModels/SessionDetailViewModel.cs
Line coverage
100%
Covered lines: 97
Uncovered lines: 0
Coverable lines: 97
Total lines: 562
Line coverage: 100%
Branch coverage
100%
Covered branches: 72
Total branches: 72
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/ViewModels/SessionDetailViewModel.cs

#LineLine coverage
 1using Chronicis.Client.Abstractions;
 2using Chronicis.Client.Services;
 3using Chronicis.Shared.DTOs;
 4using Chronicis.Shared.DTOs.Sessions;
 5using Chronicis.Shared.Enums;
 6using Chronicis.Shared.Extensions;
 7using MudBlazor;
 8
 9namespace Chronicis.Client.ViewModels;
 10
 11/// <summary>
 12/// ViewModel for the Session detail page.
 13/// </summary>
 14public sealed class SessionDetailViewModel : ViewModelBase
 15{
 16    private readonly ISessionApiService _sessionApi;
 17    private readonly IArticleApiService _articleApi;
 18    private readonly IArcApiService _arcApi;
 19    private readonly ICampaignApiService _campaignApi;
 20    private readonly IWorldApiService _worldApi;
 21    private readonly IAuthService _authService;
 22    private readonly ITreeStateService _treeState;
 23    private readonly IBreadcrumbService _breadcrumbService;
 24    private readonly IAppNavigator _navigator;
 25    private readonly IUserNotifier _notifier;
 26    private readonly IPageTitleService _titleService;
 27    private readonly ILogger<SessionDetailViewModel> _logger;
 28
 3729    private bool _isLoading = true;
 30    private bool _isSavingNotes;
 31    private bool _isGeneratingSummary;
 32    private bool _isDeletingSummary;
 33    private bool _isCreatingSessionNote;
 34    private bool _isDeleting;
 35    private bool _hasUnsavedChanges;
 36    private SessionDto? _session;
 37    private ArcDto? _arc;
 38    private CampaignDetailDto? _campaign;
 39    private WorldDetailDto? _world;
 3740    private List<ArticleTreeDto> _sessionNotes = new();
 3741    private List<BreadcrumbItem> _breadcrumbs = new();
 42    private bool _isCurrentUserGm;
 43    private bool _isCurrentUserWorldOwner;
 44    private Guid _currentUserId;
 3745    private string _editName = string.Empty;
 46    private DateTime? _editSessionDate;
 3747    private string _editPublicNotes = string.Empty;
 3748    private string _editPrivateNotes = string.Empty;
 49
 3750    public SessionDetailViewModel(
 3751        ISessionApiService sessionApi,
 3752        IArticleApiService articleApi,
 3753        IArcApiService arcApi,
 3754        ICampaignApiService campaignApi,
 3755        IWorldApiService worldApi,
 3756        IAuthService authService,
 3757        ITreeStateService treeState,
 3758        IBreadcrumbService breadcrumbService,
 3759        IAppNavigator navigator,
 3760        IUserNotifier notifier,
 3761        IPageTitleService titleService,
 3762        ILogger<SessionDetailViewModel> logger)
 63    {
 3764        _sessionApi = sessionApi;
 3765        _articleApi = articleApi;
 3766        _arcApi = arcApi;
 3767        _campaignApi = campaignApi;
 3768        _worldApi = worldApi;
 3769        _authService = authService;
 3770        _treeState = treeState;
 3771        _breadcrumbService = breadcrumbService;
 3772        _navigator = navigator;
 3773        _notifier = notifier;
 3774        _titleService = titleService;
 3775        _logger = logger;
 3776    }
 77
 17978    public bool IsLoading { get => _isLoading; private set => SetField(ref _isLoading, value); }
 11479    public bool IsSavingNotes { get => _isSavingNotes; private set => SetField(ref _isSavingNotes, value); }
 17280    public bool IsGeneratingSummary { get => _isGeneratingSummary; private set => SetField(ref _isGeneratingSummary, val
 6381    public bool IsDeletingSummary { get => _isDeletingSummary; private set => SetField(ref _isDeletingSummary, value); }
 12282    public bool IsCreatingSessionNote { get => _isCreatingSessionNote; private set => SetField(ref _isCreatingSessionNot
 26483    public bool IsDeleting { get => _isDeleting; private set => SetField(ref _isDeleting, value); }
 15784    public bool HasUnsavedChanges { get => _hasUnsavedChanges; private set => SetField(ref _hasUnsavedChanges, value); }
 70785    public SessionDto? Session { get => _session; private set => SetField(ref _session, value); }
 22086    public ArcDto? Arc { get => _arc; private set => SetField(ref _arc, value); }
 22887    public CampaignDetailDto? Campaign { get => _campaign; private set => SetField(ref _campaign, value); }
 33388    public WorldDetailDto? World { get => _world; private set => SetField(ref _world, value); }
 8489    public List<ArticleTreeDto> SessionNotes { get => _sessionNotes; private set => SetField(ref _sessionNotes, value); 
 8390    public List<BreadcrumbItem> Breadcrumbs { get => _breadcrumbs; private set => SetField(ref _breadcrumbs, value); }
 72891    public bool IsCurrentUserGM { get => _isCurrentUserGm; private set => SetField(ref _isCurrentUserGm, value); }
 18892    public bool IsCurrentUserWorldOwner { get => _isCurrentUserWorldOwner; private set => SetField(ref _isCurrentUserWor
 66693    public bool CanManageSessionDetails => IsCurrentUserGM || IsCurrentUserWorldOwner;
 8394    public bool CanViewPrivateNotes => CanManageSessionDetails;
 11695    public Guid CurrentUserId { get => _currentUserId; private set => SetField(ref _currentUserId, value); }
 5896    public bool CanCreateSessionNote => Session != null
 5897        && Arc != null
 5898        && Campaign != null
 5899        && World != null
 58100        && CurrentUserId != Guid.Empty;
 101
 102    public string EditName
 103    {
 122104        get => _editName;
 105        set
 106        {
 38107            if (SetField(ref _editName, value) && Session != null && CanManageSessionDetails)
 108            {
 6109                UpdateDirtyState();
 110            }
 38111        }
 112    }
 113
 114    public DateTime? EditSessionDate
 115    {
 71116        get => _editSessionDate;
 117        set
 118        {
 35119            if (SetField(ref _editSessionDate, value) && Session != null && CanManageSessionDetails)
 120            {
 2121                UpdateDirtyState();
 122            }
 35123        }
 124    }
 125
 126    public string EditPublicNotes
 127    {
 36128        get => _editPublicNotes;
 129        set
 130        {
 35131            if (SetField(ref _editPublicNotes, value) && Session != null && CanManageSessionDetails)
 132            {
 4133                UpdateDirtyState();
 134            }
 35135        }
 136    }
 137
 138    public string EditPrivateNotes
 139    {
 36140        get => _editPrivateNotes;
 141        set
 142        {
 33143            if (SetField(ref _editPrivateNotes, value) && Session != null && CanManageSessionDetails)
 144            {
 2145                UpdateDirtyState();
 146            }
 33147        }
 148    }
 149
 150    public async Task LoadAsync(Guid sessionId)
 151    {
 152        IsLoading = true;
 153
 154        try
 155        {
 156            var session = await _sessionApi.GetSessionAsync(sessionId);
 157            if (session == null)
 158            {
 159                _navigator.NavigateTo("/dashboard", replace: true);
 160                return;
 161            }
 162
 163            Session = session;
 164            EditName = session.Name;
 165            EditSessionDate = session.SessionDate?.Date;
 166            EditPublicNotes = session.PublicNotes ?? string.Empty;
 167            EditPrivateNotes = session.PrivateNotes ?? string.Empty;
 168            HasUnsavedChanges = false;
 169
 170            _treeState.ExpandPathToAndSelect(sessionId);
 171
 172            Arc = await _arcApi.GetArcAsync(session.ArcId);
 173            if (Arc != null)
 174            {
 175                Campaign = await _campaignApi.GetCampaignAsync(Arc.CampaignId);
 176                if (Campaign != null)
 177                {
 178                    World = await _worldApi.GetWorldAsync(Campaign.WorldId);
 179                }
 180            }
 181
 182            await ResolveCurrentUserRoleAsync();
 183            Breadcrumbs = BuildBreadcrumbs();
 184            await LoadSessionNotesAsync(sessionId);
 185            await _titleService.SetTitleAsync(session.Name);
 186        }
 187        catch (Exception ex)
 188        {
 189            _logger.LogErrorSanitized(ex, "Error loading session {SessionId}", sessionId);
 190            _notifier.Error($"Failed to load session: {ex.Message}");
 191        }
 192        finally
 193        {
 194            IsLoading = false;
 195        }
 196    }
 197
 198    public async Task SaveNotesAsync()
 199    {
 200        if (Session == null || !CanManageSessionDetails || IsSavingNotes)
 201        {
 202            return;
 203        }
 204
 205        var trimmedName = (EditName ?? string.Empty).Trim();
 206        if (string.IsNullOrWhiteSpace(trimmedName))
 207        {
 208            _notifier.Error("Session title is required");
 209            return;
 210        }
 211
 212        if (trimmedName.Length > 500)
 213        {
 214            _notifier.Error("Session title must be 500 characters or fewer");
 215            return;
 216        }
 217
 218        var editedSessionDate = EditSessionDate?.Date;
 219        var shouldClearSessionDate = Session.SessionDate.HasValue && !editedSessionDate.HasValue;
 220
 221        IsSavingNotes = true;
 222
 223        try
 224        {
 225            var updated = await _sessionApi.UpdateSessionNotesAsync(Session.Id, new SessionUpdateDto
 226            {
 227                Name = trimmedName,
 228                SessionDate = editedSessionDate,
 229                ClearSessionDate = shouldClearSessionDate,
 230                PublicNotes = string.IsNullOrWhiteSpace(EditPublicNotes) ? null : EditPublicNotes,
 231                PrivateNotes = string.IsNullOrWhiteSpace(EditPrivateNotes) ? null : EditPrivateNotes
 232            });
 233
 234            if (updated == null)
 235            {
 236                _notifier.Error("Failed to save session notes");
 237                return;
 238            }
 239
 240            Session = updated;
 241            EditName = updated.Name;
 242            EditSessionDate = updated.SessionDate?.Date;
 243            EditPublicNotes = updated.PublicNotes ?? string.Empty;
 244            EditPrivateNotes = updated.PrivateNotes ?? string.Empty;
 245            Breadcrumbs = BuildBreadcrumbs();
 246            await _titleService.SetTitleAsync(updated.Name);
 247            await _treeState.RefreshAsync();
 248            HasUnsavedChanges = false;
 249            _notifier.Success("Session saved");
 250        }
 251        catch (Exception ex)
 252        {
 253            _logger.LogErrorSanitized(ex, "Error saving session {SessionId}", Session.Id);
 254            _notifier.Error($"Failed to save session: {ex.Message}");
 255        }
 256        finally
 257        {
 258            IsSavingNotes = false;
 259        }
 260    }
 261
 262    public async Task GenerateAiSummaryAsync()
 263    {
 264        if (Session == null || IsGeneratingSummary)
 265        {
 266            return;
 267        }
 268
 269        var hadSummary = !string.IsNullOrWhiteSpace(Session.AiSummary);
 270        IsGeneratingSummary = true;
 271
 272        try
 273        {
 274            var result = await _sessionApi.GenerateAiSummaryAsync(Session.Id);
 275            if (result?.Success != true)
 276            {
 277                _notifier.Error(result?.ErrorMessage ?? "Failed to generate AI summary");
 278                return;
 279            }
 280
 281            Session.AiSummary = result.Summary;
 282            Session.AiSummaryGeneratedAt = result.GeneratedDate;
 283            RaisePropertyChanged(nameof(Session));
 284
 285            _notifier.Success(hadSummary ? "AI summary refreshed" : "AI summary generated");
 286        }
 287        catch (Exception ex)
 288        {
 289            _logger.LogErrorSanitized(ex, "Error generating AI summary for session {SessionId}", Session.Id);
 290            _notifier.Error($"Failed to generate summary: {ex.Message}");
 291        }
 292        finally
 293        {
 294            IsGeneratingSummary = false;
 295        }
 296    }
 297
 298    public async Task ClearAiSummaryAsync()
 299    {
 300        if (Session == null || IsDeletingSummary || string.IsNullOrWhiteSpace(Session.AiSummary))
 301        {
 302            return;
 303        }
 304
 305        IsDeletingSummary = true;
 306
 307        try
 308        {
 309            var cleared = await _sessionApi.ClearAiSummaryAsync(Session.Id);
 310            if (!cleared)
 311            {
 312                _notifier.Error("Failed to delete AI summary");
 313                return;
 314            }
 315
 316            Session.AiSummary = null;
 317            Session.AiSummaryGeneratedAt = null;
 318            Session.AiSummaryGeneratedByUserId = null;
 319            RaisePropertyChanged(nameof(Session));
 320
 321            _notifier.Success("AI summary deleted");
 322        }
 323        catch (Exception ex)
 324        {
 325            _logger.LogErrorSanitized(ex, "Error clearing AI summary for session {SessionId}", Session.Id);
 326            _notifier.Error($"Failed to delete AI summary: {ex.Message}");
 327        }
 328        finally
 329        {
 330            IsDeletingSummary = false;
 331        }
 332    }
 333
 334    public async Task DeleteSessionAsync()
 335    {
 336        if (Session == null || !IsCurrentUserGM || IsDeleting)
 337        {
 338            return;
 339        }
 340
 341        IsDeleting = true;
 342
 343        var sessionId = Session.Id;
 344        var sessionName = Session.Name;
 345        var arcId = Arc?.Id ?? Session.ArcId;
 346
 347        try
 348        {
 349            var deleted = await _sessionApi.DeleteSessionAsync(sessionId);
 350            if (!deleted)
 351            {
 352                _notifier.Error("Failed to delete session");
 353                return;
 354            }
 355
 356            await _treeState.RefreshAsync();
 357            _notifier.Success("Session deleted");
 358            _navigator.NavigateTo($"/arc/{arcId}", replace: true);
 359        }
 360        catch (Exception ex)
 361        {
 362            _logger.LogErrorSanitized(ex, "Error deleting session {SessionId}", sessionId);
 363            _notifier.Error($"Failed to delete session '{sessionName}': {ex.Message}");
 364        }
 365        finally
 366        {
 367            IsDeleting = false;
 368        }
 369    }
 370
 371    public async Task CreateSessionNoteAsync()
 372    {
 373        if (!CanCreateSessionNote || Session == null || Arc == null || Campaign == null || World == null || IsCreatingSe
 374        {
 375            return;
 376        }
 377
 378        IsCreatingSessionNote = true;
 379        ArticleDto? createdNote = null;
 380        var attachedToSession = false;
 381
 382        try
 383        {
 384            var user = await _authService.GetCurrentUserAsync();
 385            var title = BuildDefaultSessionNoteTitle(user?.DisplayName);
 386
 387            createdNote = await _articleApi.CreateArticleAsync(new ArticleCreateDto
 388            {
 389                Title = title,
 390                WorldId = World.Id,
 391                CampaignId = Campaign.Id,
 392                ArcId = Arc.Id,
 393                Type = ArticleType.SessionNote,
 394                Visibility = ArticleVisibility.Public
 395            });
 396
 397            if (createdNote == null)
 398            {
 399                _notifier.Error("Failed to create session note");
 400                return;
 401            }
 402
 403            attachedToSession = await _articleApi.MoveArticleAsync(createdNote.Id, newParentId: null, newSessionId: Sess
 404            if (!attachedToSession)
 405            {
 406                var deleted = await _articleApi.DeleteArticleAsync(createdNote.Id);
 407                if (!deleted)
 408                {
 409                    _logger.LogWarning(
 410                        "Failed to delete orphan session note {ArticleId} after attach failure for session {SessionId}",
 411                        createdNote.Id,
 412                        Session.Id);
 413                }
 414
 415                _notifier.Error("Failed to attach session note to this session");
 416                return;
 417            }
 418
 419            await _treeState.RefreshAsync();
 420            await LoadSessionNotesAsync(Session.Id);
 421            _notifier.Success("Session note added");
 422
 423            await OpenSessionNoteAsync(new ArticleTreeDto
 424            {
 425                Id = createdNote.Id,
 426                Slug = createdNote.Slug,
 427                Title = createdNote.Title
 428            });
 429        }
 430        catch (Exception ex)
 431        {
 432            _logger.LogErrorSanitized(ex, "Error creating session note for session {SessionId}", Session.Id);
 433
 434            if (createdNote != null && !attachedToSession)
 435            {
 436                try
 437                {
 438                    await _articleApi.DeleteArticleAsync(createdNote.Id);
 439                }
 440                catch (Exception cleanupEx)
 441                {
 442                    _logger.LogWarning(cleanupEx, "Cleanup failed for created session note {ArticleId}", createdNote.Id)
 443                }
 444            }
 445
 446            _notifier.Error($"Failed to create session note: {ex.Message}");
 447        }
 448        finally
 449        {
 450            IsCreatingSessionNote = false;
 451        }
 452    }
 453
 454    public async Task OpenSessionNoteAsync(ArticleTreeDto note)
 455    {
 456        try
 457        {
 458            var article = await _articleApi.GetArticleDetailAsync(note.Id);
 459            if (article != null && article.Breadcrumbs.Any())
 460            {
 461                _navigator.NavigateTo(_breadcrumbService.BuildArticleUrl(article.Breadcrumbs));
 462                return;
 463            }
 464
 465            _navigator.NavigateTo($"/article/{note.Slug}");
 466        }
 467        catch (Exception ex)
 468        {
 469            _logger.LogErrorSanitized(ex, "Error navigating to session note {ArticleId}", note.Id);
 470            _notifier.Error("Failed to navigate to session note");
 471        }
 472    }
 473
 474    private async Task LoadSessionNotesAsync(Guid sessionId)
 475    {
 476        var allArticles = await _articleApi.GetAllArticlesAsync();
 477        SessionNotes = allArticles
 478            .Where(a => a.Type == ArticleType.SessionNote && a.SessionId == sessionId)
 479            .OrderBy(a => a.Title)
 480            .ThenBy(a => a.CreatedAt)
 481            .ToList();
 482    }
 483
 484    private void UpdateDirtyState()
 485    {
 17486        if (Session == null || !CanManageSessionDetails)
 487        {
 2488            HasUnsavedChanges = false;
 2489            return;
 490        }
 491
 15492        var nameChanged = !string.Equals(Session.Name ?? string.Empty, EditName ?? string.Empty, StringComparison.Ordina
 15493        var dateChanged = !AreSameDate(Session.SessionDate, EditSessionDate);
 15494        var publicChanged = !string.Equals(Session.PublicNotes ?? string.Empty, EditPublicNotes ?? string.Empty, StringC
 15495        var privateChanged = !string.Equals(Session.PrivateNotes ?? string.Empty, EditPrivateNotes ?? string.Empty, Stri
 15496        HasUnsavedChanges = nameChanged || dateChanged || publicChanged || privateChanged;
 15497    }
 498
 499    private async Task ResolveCurrentUserRoleAsync()
 500    {
 501        IsCurrentUserGM = false;
 502        IsCurrentUserWorldOwner = false;
 503        CurrentUserId = Guid.Empty;
 504
 505        if (World == null)
 506        {
 507            return;
 508        }
 509
 510        var user = await _authService.GetCurrentUserAsync();
 511        if (user == null || World.Members == null)
 512        {
 513            return;
 514        }
 515
 516        var member = World.Members.FirstOrDefault(m =>
 517            m.Email.Equals(user.Email, StringComparison.OrdinalIgnoreCase));
 518
 519        if (member == null)
 520        {
 521            return;
 522        }
 523
 524        CurrentUserId = member.UserId;
 525        IsCurrentUserGM = member.Role == WorldRole.GM;
 526        IsCurrentUserWorldOwner = member.UserId == World.OwnerId;
 527    }
 528
 529    private List<BreadcrumbItem> BuildBreadcrumbs()
 530    {
 31531        if (Session == null)
 532        {
 1533            return new List<BreadcrumbItem> { new("Dashboard", href: "/dashboard") };
 534        }
 535
 30536        if (Arc != null && Campaign != null && World != null)
 537        {
 29538            var items = _breadcrumbService.ForArc(Arc, Campaign, World, currentDisabled: false);
 29539            items.Add(new BreadcrumbItem(Session.Name, href: null, disabled: true));
 29540            return items;
 541        }
 542
 1543        return new List<BreadcrumbItem>
 1544        {
 1545            new("Dashboard", href: "/dashboard"),
 1546            new(Session.Name, href: null, disabled: true)
 1547        };
 548    }
 549
 550    private static bool AreSameDate(DateTime? left, DateTime? right)
 17551        => left?.Date == right?.Date;
 552
 553    private static string BuildDefaultSessionNoteTitle(string? displayName)
 554    {
 7555        var trimmed = displayName?.Trim();
 7556        var title = string.IsNullOrWhiteSpace(trimmed)
 7557            ? "My Notes"
 7558            : $"{trimmed}'s Notes";
 559
 7560        return title.Length <= 500 ? title : title[..500];
 561    }
 562}