using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.Models.Forum; using Nuuru.Server.Services.BBCode; using static Nuuru.Server.Data.AvatarLookupExtensions; namespace Nuuru.Server.Services { public interface IForumThreadService { Task CreateThreadAsync(Guid categoryId, Guid authorId, string title, string content, string? ipAddress = null); Task GetThreadByIdAsync(int threadId); Task GetThreadAccessInfoAsync(int threadId); Task> GetThreadsByCategoryAsync(Guid categoryId, int page = 1, int pageSize = 20); Task GetThreadCountByCategoryAsync(Guid categoryId); Task IncrementViewCountAsync(int threadId); Task IsUserBannedAsync(int threadId, Guid userId); Task BanUserAsync(int threadId, Guid actorUserId, Guid targetUserId); Task UnbanUserAsync(int threadId, Guid actorUserId, Guid targetUserId); // Moderation Task PinThreadAsync(int threadId, bool pinned); Task LockThreadAsync(int threadId, bool locked); Task MoveThreadAsync(int threadId, Guid targetCategoryId); Task DeleteThreadAsync(int threadId); } public sealed class ForumThreadAccessInfo { public int Id { get; init; } public Guid CategoryId { get; init; } public string CategorySlug { get; init; } = string.Empty; public bool IsLocked { get; init; } public int? FirstPostId { get; init; } public int ReplyCount { get; init; } public int TotalPostCount => ReplyCount + (FirstPostId.HasValue ? 1 : 0); } public class ForumThreadService : IForumThreadService { private readonly ApplicationDbContext _context; private readonly IBBCodeService _bbCodeService; private readonly IHtmlEnrichmentService _htmlEnrichmentService; private readonly IForumPostHtmlEnrichmentScheduler _htmlEnrichmentScheduler; private readonly INotificationService _notificationService; private readonly IWatchService _watchService; private readonly IForumAttachmentService _attachmentService; private readonly IUserSettingsService _settingsService; private readonly ILogger _logger; public ForumThreadService( ApplicationDbContext context, IBBCodeService bbCodeService, IHtmlEnrichmentService htmlEnrichmentService, IForumPostHtmlEnrichmentScheduler htmlEnrichmentScheduler, INotificationService notificationService, IWatchService watchService, IForumAttachmentService attachmentService, IUserSettingsService settingsService, ILogger logger) { _context = context; _bbCodeService = bbCodeService; _htmlEnrichmentService = htmlEnrichmentService; _htmlEnrichmentScheduler = htmlEnrichmentScheduler; _notificationService = notificationService; _watchService = watchService; _attachmentService = attachmentService; _settingsService = settingsService; _logger = logger; } public async Task CreateThreadAsync(Guid categoryId, Guid authorId, string title, string content, string? ipAddress = null) { // Validate category exists var category = await _context.ForumCategories.FindAsync(categoryId); if (category == null) { _logger.LogWarning("Category {CategoryId} not found when creating thread", categoryId); return null; } // Validate user exists var user = await _context.Users.FindAsync(authorId); if (user == null) { _logger.LogWarning("User {UserId} not found when creating thread", authorId); return null; } // Parse BBCode to HTML and extract mentions in one pass (with Forum context for attachments) var parseResult = _bbCodeService.ParseWithMentions(content, BBCodeContext.Forum, _context.CreateAvatarLookup()); var enriched = await _htmlEnrichmentService.EnrichImmediateAsync(parseResult.Html, HtmlEnrichmentContext.ForForumPost()); // Create the thread first (without FirstPostId to avoid circular dependency) var thread = new ForumThread { Title = title, CategoryId = categoryId, AuthorId = authorId, CreatedAt = DateTime.UtcNow, LastPostAt = DateTime.UtcNow }; _context.ForumThreads.Add(thread); await _context.SaveChangesAsync(); // Create the first post (OP) now that thread exists var firstPost = new ForumPost { ContentRaw = content, ContentHtml = enriched.Html, ContentHtmlVersion = enriched.Version, ThreadId = thread.Id, AuthorId = authorId, CreatedAt = DateTime.UtcNow, IpAddress = ipAddress }; _context.ForumPosts.Add(firstPost); await _context.SaveChangesAsync(); // Now link the first post to the thread thread.FirstPostId = firstPost.Id; thread.LastPostId = firstPost.Id; // Store mentions in relation table if (parseResult.MentionedUserIds.Count > 0) { foreach (var mentionedUserId in parseResult.MentionedUserIds) { _context.ForumPostMentions.Add(new ForumPostMention { ForumPostId = firstPost.Id, MentionedUserId = mentionedUserId }); } } await _context.SaveChangesAsync(); if (_htmlEnrichmentService.NeedsEnrichment(firstPost.ContentHtml, firstPost.ContentHtmlVersion, HtmlEnrichmentContext.ForForumPost(firstPost.Id))) _htmlEnrichmentScheduler.Enqueue(firstPost.Id); // Reload with includes for the response var result = await GetThreadByIdAsync(thread.Id); _logger.LogInformation("Thread {ThreadId} created by user {UserId} in category {CategoryId}", thread.Id, authorId, categoryId); // Auto-watch the thread for the creator if preference is enabled var prefs = await _settingsService.GetAutoWatchPreferencesAsync(authorId); if (prefs.AutoWatchOnForumThreadCreate) { await _watchService.EnsureWatchingAsync(authorId, Models.WatchTargetType.ForumThread, thread.Id); } // Create mention notifications for the first post await _notificationService.CreateForumMentionNotificationsAsync(parseResult.MentionedUserIds, firstPost.Id, authorId); // Create quote notifications for the first post await _notificationService.CreateForumQuoteNotificationsAsync(parseResult.QuotedForumPostIds, firstPost.Id, authorId); return result; } public async Task GetThreadByIdAsync(int threadId) { var thread = await _context.ForumThreads .Include(t => t.Author) .Include(t => t.UserBans) .ThenInclude(b => b.User) .Include(t => t.LastPost) .ThenInclude(p => p!.Author) .Include(t => t.Category) .Include(t => t.FirstPost) .ThenInclude(p => p!.Author) .AsSplitQuery() .FirstOrDefaultAsync(t => t.Id == threadId); if (thread?.FirstPost != null) QueueStoredHtmlEnrichment([thread.FirstPost]); return thread; } public async Task GetThreadAccessInfoAsync(int threadId) { return await _context.ForumThreads .AsNoTracking() .Where(t => t.Id == threadId) .Select(t => new ForumThreadAccessInfo { Id = t.Id, CategoryId = t.CategoryId, CategorySlug = t.Category.Slug, IsLocked = t.IsLocked, FirstPostId = t.FirstPostId, ReplyCount = t.ReplyCount }) .FirstOrDefaultAsync(); } public async Task> GetThreadsByCategoryAsync(Guid categoryId, int page = 1, int pageSize = 20) { var threads = await _context.ForumThreads .Include(t => t.Author) .Include(t => t.LastPost) .ThenInclude(p => p!.Author) .Include(t => t.Category) .Where(t => t.CategoryId == categoryId) .OrderByDescending(t => t.IsPinned) // Pinned first .ThenByDescending(t => t.LastPostAt) // Then by activity .Skip((page - 1) * pageSize) .Take(pageSize) .AsSplitQuery() .ToListAsync(); return threads; } public async Task GetThreadCountByCategoryAsync(Guid categoryId) { return await _context.ForumThreads .CountAsync(t => t.CategoryId == categoryId); } public async Task IsUserBannedAsync(int threadId, Guid userId) { var categorySlug = await _context.ForumThreads .Where(t => t.Id == threadId) .Select(t => t.Category.Slug) .FirstOrDefaultAsync(); if (string.Equals(categorySlug, "gen", StringComparison.OrdinalIgnoreCase)) { return false; } return await _context.ForumThreadBans .AnyAsync(b => b.ThreadId == threadId && b.UserId == userId); } public async Task IncrementViewCountAsync(int threadId) { var thread = await _context.ForumThreads.FindAsync(threadId); if (thread == null) return false; thread.ViewCount++; await _context.SaveChangesAsync(); return true; } public async Task BanUserAsync(int threadId, Guid actorUserId, Guid targetUserId) { var thread = await _context.ForumThreads .Include(t => t.UserBans) .FirstOrDefaultAsync(t => t.Id == threadId); if (thread == null) { _logger.LogWarning("Thread {ThreadId} not found for ban operation", threadId); return false; } if (thread.UserBans.Any(b => b.UserId == targetUserId)) { return true; } var targetUserExists = await _context.Users.AnyAsync(u => u.Id == targetUserId); if (!targetUserExists) { _logger.LogWarning("User {UserId} not found for thread ban in thread {ThreadId}", targetUserId, threadId); return false; } _context.ForumThreadBans.Add(new ForumThreadBan { ThreadId = threadId, UserId = targetUserId, BannedByUserId = actorUserId, CreatedAt = DateTime.UtcNow }); await _context.SaveChangesAsync(); _logger.LogInformation("User {TargetUserId} banned from thread {ThreadId} by {ActorUserId}", targetUserId, threadId, actorUserId); return true; } public async Task UnbanUserAsync(int threadId, Guid actorUserId, Guid targetUserId) { var ban = await _context.ForumThreadBans .FirstOrDefaultAsync(b => b.ThreadId == threadId && b.UserId == targetUserId); if (ban == null) { return true; } _context.ForumThreadBans.Remove(ban); await _context.SaveChangesAsync(); _logger.LogInformation("User {TargetUserId} unbanned from thread {ThreadId} by {ActorUserId}", targetUserId, threadId, actorUserId); return true; } public async Task PinThreadAsync(int threadId, bool pinned) { var thread = await _context.ForumThreads.FindAsync(threadId); if (thread == null) { _logger.LogWarning("Thread {ThreadId} not found for pin operation", threadId); return false; } thread.IsPinned = pinned; await _context.SaveChangesAsync(); _logger.LogInformation("Thread {ThreadId} {Action}", threadId, pinned ? "pinned" : "unpinned"); return true; } public async Task LockThreadAsync(int threadId, bool locked) { var thread = await _context.ForumThreads.FindAsync(threadId); if (thread == null) { _logger.LogWarning("Thread {ThreadId} not found for lock operation", threadId); return false; } thread.IsLocked = locked; await _context.SaveChangesAsync(); _logger.LogInformation("Thread {ThreadId} {Action}", threadId, locked ? "locked" : "unlocked"); return true; } public async Task MoveThreadAsync(int threadId, Guid targetCategoryId) { var thread = await _context.ForumThreads.FindAsync(threadId); if (thread == null) { _logger.LogWarning("Thread {ThreadId} not found for move operation", threadId); return false; } var targetCategory = await _context.ForumCategories.FindAsync(targetCategoryId); if (targetCategory == null) { _logger.LogWarning("Target category {CategoryId} not found for move operation", targetCategoryId); return false; } var oldCategoryId = thread.CategoryId; thread.CategoryId = targetCategoryId; await _context.SaveChangesAsync(); _logger.LogInformation("Thread {ThreadId} moved from {OldCategoryId} to {NewCategoryId}", threadId, oldCategoryId, targetCategoryId); return true; } public async Task DeleteThreadAsync(int threadId) { var thread = await _context.ForumThreads .Include(t => t.Posts) .FirstOrDefaultAsync(t => t.Id == threadId); if (thread == null) { _logger.LogWarning("Thread {ThreadId} not found for deletion", threadId); return false; } // Delete attachment files for all posts in the thread foreach (var post in thread.Posts) { await _attachmentService.DeleteAttachmentsForPostAsync(post.Id); } // Break circular FK (Thread.FirstPostId/LastPostId ↔ Post.ThreadId) before delete thread.FirstPostId = null; thread.LastPostId = null; await _context.SaveChangesAsync(); _context.ForumThreads.Remove(thread); await _context.SaveChangesAsync(); _logger.LogInformation("Thread {ThreadId} deleted", threadId); return true; } private void QueueStoredHtmlEnrichment(IEnumerable posts) { foreach (var post in posts) { if (!_htmlEnrichmentService.NeedsEnrichment(post.ContentHtml, post.ContentHtmlVersion, HtmlEnrichmentContext.ForForumPost(post.Id))) continue; _htmlEnrichmentScheduler.Enqueue(post.Id); } } } }