| | | 1 | | using Chronicis.Api.Data; |
| | | 2 | | using Chronicis.Api.Models; |
| | | 3 | | using Chronicis.Shared.DTOs; |
| | | 4 | | using Chronicis.Shared.DTOs.Sessions; |
| | | 5 | | using Chronicis.Shared.Enums; |
| | | 6 | | using Chronicis.Shared.Models; |
| | | 7 | | using Chronicis.Shared.Utilities; |
| | | 8 | | using Microsoft.EntityFrameworkCore; |
| | | 9 | | |
| | | 10 | | namespace Chronicis.Api.Services; |
| | | 11 | | |
| | | 12 | | public 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 | | { |
| | 7 | 25 | | _context = context; |
| | 7 | 26 | | _summaryService = summaryService; |
| | 7 | 27 | | _worldDocumentService = worldDocumentService; |
| | 7 | 28 | | _logger = logger; |
| | 7 | 29 | | } |
| | | 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 | | { |
| | 5 | 498 | | var trimmed = username?.Trim(); |
| | 5 | 499 | | var title = string.IsNullOrWhiteSpace(trimmed) |
| | 5 | 500 | | ? "My Notes" |
| | 5 | 501 | | : $"{trimmed}'s Notes"; |
| | | 502 | | |
| | 5 | 503 | | return title.Length <= 500 ? title : title[..500]; |
| | | 504 | | } |
| | | 505 | | |
| | | 506 | | private static SessionDto MapDto(Session session) |
| | | 507 | | { |
| | 4 | 508 | | return new SessionDto |
| | 4 | 509 | | { |
| | 4 | 510 | | Id = session.Id, |
| | 4 | 511 | | ArcId = session.ArcId, |
| | 4 | 512 | | Name = session.Name, |
| | 4 | 513 | | SessionDate = session.SessionDate, |
| | 4 | 514 | | PublicNotes = session.PublicNotes, |
| | 4 | 515 | | PrivateNotes = session.PrivateNotes, |
| | 4 | 516 | | AiSummary = session.AiSummary, |
| | 4 | 517 | | AiSummaryGeneratedAt = session.AiSummaryGeneratedAt, |
| | 4 | 518 | | AiSummaryGeneratedByUserId = session.AiSummaryGeneratedByUserId, |
| | 4 | 519 | | CreatedAt = session.CreatedAt, |
| | 4 | 520 | | ModifiedAt = session.ModifiedAt, |
| | 4 | 521 | | CreatedBy = session.CreatedBy |
| | 4 | 522 | | }; |
| | | 523 | | } |
| | | 524 | | } |