using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Nuuru.Server.Data; using Nuuru.Server.Models; using Nuuru.Server.Models.Forum; using Nuuru.Server.Services.BBCode; using static Nuuru.Server.Data.AvatarLookupExtensions; namespace Nuuru.Server.Services { public interface IForumPostService { Task CreatePostAsync(int threadId, Guid authorId, string content, string? ipAddress = null); Task GetPostByIdAsync(int postId); Task> GetPostsByThreadAsync(int threadId, int page = 1, int pageSize = 20); Task GetPostCountByThreadAsync(int threadId); Task GetPostPageAsync(int threadId, int postId, int pageSize = 20); Task> GetLatestPostsAsync(int count = 10); Task UpdatePostAsync(int postId, string content); Task DeletePostAsync(int postId); Task> GetHighlightedPostIdsAsync(int threadId, int? totalPostCount = null, int minReactions = 5); } public class ForumPostService : IForumPostService { private readonly ApplicationDbContext _context; private readonly IBBCodeService _bbCodeService; private readonly INotificationService _notificationService; private readonly IWatchService _watchService; private readonly IHtmlEnrichmentService _htmlEnrichmentService; private readonly IForumPostHtmlEnrichmentScheduler _htmlEnrichmentScheduler; private readonly IForumAttachmentService _attachmentService; private readonly IUserSettingsService _settingsService; private readonly IBointsService _bointsService; private readonly IMemoryCache _cache; private readonly ILogger _logger; public ForumPostService( ApplicationDbContext context, IBBCodeService bbCodeService, INotificationService notificationService, IWatchService watchService, IHtmlEnrichmentService htmlEnrichmentService, IForumPostHtmlEnrichmentScheduler htmlEnrichmentScheduler, IForumAttachmentService attachmentService, IUserSettingsService settingsService, IBointsService bointsService, ILogger logger, IMemoryCache? cache = null) { _context = context; _bbCodeService = bbCodeService; _notificationService = notificationService; _watchService = watchService; _htmlEnrichmentService = htmlEnrichmentService; _htmlEnrichmentScheduler = htmlEnrichmentScheduler; _attachmentService = attachmentService; _settingsService = settingsService; _bointsService = bointsService; _cache = cache ?? new MemoryCache(new MemoryCacheOptions()); _logger = logger; } public async Task CreatePostAsync(int threadId, Guid authorId, string content, string? ipAddress = null) { // Validate thread exists and is not locked var thread = await _context.ForumThreads .Include(t => t.Category) .FirstOrDefaultAsync(t => t.Id == threadId); if (thread == null) { _logger.LogWarning("Thread {ThreadId} not found when creating post", threadId); return null; } if (thread.IsLocked) { _logger.LogWarning("Cannot create post in locked thread {ThreadId}", threadId); return null; } var threadBansEnabled = !string.Equals(thread.Category?.Slug, "gen", StringComparison.OrdinalIgnoreCase); if (threadBansEnabled) { var isBannedFromThread = await _context.ForumThreadBans .AnyAsync(b => b.ThreadId == threadId && b.UserId == authorId); if (isBannedFromThread) { _logger.LogWarning("Cannot create post in thread {ThreadId} because user {UserId} is banned", threadId, authorId); return null; } } // Validate user exists var user = await _context.Users.FindAsync(authorId); if (user == null) { _logger.LogWarning("User {UserId} not found when creating post", authorId); return null; } // Parse BBCode to HTML and extract mentions in one pass (with Forum context for attachments) var parseResult = _bbCodeService.ParseWithMentions(content, BBCode.BBCodeContext.Forum, _context.CreateAvatarLookup()); var enriched = await _htmlEnrichmentService.EnrichImmediateAsync(parseResult.Html, HtmlEnrichmentContext.ForForumPost()); var post = new ForumPost { ContentRaw = content, ContentHtml = enriched.Html, ContentHtmlVersion = enriched.Version, ThreadId = threadId, Thread = thread, AuthorId = authorId, Author = user, CreatedAt = DateTime.UtcNow, IpAddress = ipAddress }; await using var transaction = await _context.Database.BeginTransactionAsync(); _context.ForumPosts.Add(post); await _context.SaveChangesAsync(); // Update thread stats after post has a real database ID thread.ReplyCount++; thread.LastPostAt = post.CreatedAt; thread.LastPostId = post.Id; // Store mentions in relation table if (parseResult.MentionedUserIds.Count > 0) { foreach (var mentionedUserId in parseResult.MentionedUserIds) { _context.ForumPostMentions.Add(new ForumPostMention { ForumPostId = post.Id, MentionedUserId = mentionedUserId }); } } await _context.SaveChangesAsync(); await transaction.CommitAsync(); if (_htmlEnrichmentService.NeedsEnrichment(post.ContentHtml, post.ContentHtmlVersion, HtmlEnrichmentContext.ForForumPost(post.Id))) _htmlEnrichmentScheduler.Enqueue(post.Id); _logger.LogInformation("Post {PostId} created by user {UserId} in thread {ThreadId}", post.Id, authorId, threadId); // Auto-watch the thread for the replier if preference is enabled var prefs = await _settingsService.GetAutoWatchPreferencesAsync(authorId); if (prefs.AutoWatchOnForumThreadReply) { await _watchService.EnsureWatchingAsync(authorId, Models.WatchTargetType.ForumThread, threadId); } // Create mention notifications await _notificationService.CreateForumMentionNotificationsAsync(parseResult.MentionedUserIds, post.Id, authorId); // Create quote notifications and capture notified users for dedup var quotedUserIds = await _notificationService.CreateForumQuoteNotificationsAsync(parseResult.QuotedForumPostIds, post.Id, authorId); // Create watched thread reply notifications, excluding users who already got a quote notification await _notificationService.CreateWatchedThreadReplyNotificationsAsync(threadId, post.Id, authorId, quotedUserIds); // Boints: 1 per forum post, max once per 10 minutes await _bointsService.CreditAsync(authorId, BointsReason.ForumPostCreated, 1, sourceForumPostId: post.Id); return post; } public async Task GetPostByIdAsync(int postId) { var post = await _context.ForumPosts .Include(p => p.Author) .Include(p => p.Thread) .ThenInclude(t => t!.Category) .FirstOrDefaultAsync(p => p.Id == postId); if (post != null) QueueStoredHtmlEnrichment([post]); return post; } public async Task> GetPostsByThreadAsync(int threadId, int page = 1, int pageSize = 20) { var posts = await _context.ForumPosts .AsNoTracking() .Include(p => p.Author) .Where(p => p.ThreadId == threadId) .OrderBy(p => p.CreatedAt) // Oldest first for forum posts .ThenBy(p => p.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); QueueStoredHtmlEnrichment(posts); return posts; } public async Task GetPostCountByThreadAsync(int threadId) { return await _context.ForumPosts .CountAsync(p => p.ThreadId == threadId); } public async Task GetPostPageAsync(int threadId, int postId, int pageSize = 20) { // Get the post to verify it exists and belongs to the thread var post = await _context.ForumPosts .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == postId && p.ThreadId == threadId); if (post == null) { return null; } // Count how many posts come before this one (ordered by CreatedAt) var position = await _context.ForumPosts .CountAsync(p => p.ThreadId == threadId && (p.CreatedAt < post.CreatedAt || (p.CreatedAt == post.CreatedAt && p.Id < post.Id))); // Calculate the page (1-indexed) var page = (position / pageSize) + 1; return page; } public async Task> GetLatestPostsAsync(int count = 10) { var posts = await _context.ForumPosts .Include(p => p.Author) .Include(p => p.Thread) .ThenInclude(t => t.Category) .OrderByDescending(p => p.CreatedAt) .Take(count) .ToListAsync(); QueueStoredHtmlEnrichment(posts); return posts; } public async Task UpdatePostAsync(int postId, string content) { var post = await _context.ForumPosts .Include(p => p.Author) .Include(p => p.Mentions) .Include(p => p.Thread) .ThenInclude(t => t!.Category) .FirstOrDefaultAsync(p => p.Id == postId); if (post == null) { _logger.LogWarning("Post {PostId} not found for update", postId); return null; } // Re-parse BBCode to HTML and extract mentions (with Forum context for attachments) var parseResult = _bbCodeService.ParseWithMentions(content, BBCode.BBCodeContext.Forum, _context.CreateAvatarLookup()); var enriched = await _htmlEnrichmentService.EnrichImmediateAsync(parseResult.Html, HtmlEnrichmentContext.ForForumPost(post.Id)); post.ContentRaw = content; post.ContentHtml = enriched.Html; post.ContentHtmlVersion = enriched.Version; post.EditedAt = DateTime.UtcNow; // Clear old mentions and add new ones _context.ForumPostMentions.RemoveRange(post.Mentions); foreach (var mentionedUserId in parseResult.MentionedUserIds) { _context.ForumPostMentions.Add(new ForumPostMention { ForumPostId = post.Id, MentionedUserId = mentionedUserId }); } await _context.SaveChangesAsync(); if (_htmlEnrichmentService.NeedsEnrichment(post.ContentHtml, post.ContentHtmlVersion, HtmlEnrichmentContext.ForForumPost(post.Id))) _htmlEnrichmentScheduler.Enqueue(post.Id); _logger.LogInformation("Post {PostId} updated", postId); return post; } 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); } } public async Task DeletePostAsync(int postId) { var post = await _context.ForumPosts .Include(p => p.Thread) .ThenInclude(t => t!.Category) .FirstOrDefaultAsync(p => p.Id == postId); if (post == null) { _logger.LogWarning("Post {PostId} not found for deletion", postId); return false; } // Don't allow deleting the first post - must delete the whole thread if (post.Thread.FirstPostId == postId) { _logger.LogWarning("Cannot delete first post {PostId} of thread {ThreadId}. Delete the thread instead.", postId, post.ThreadId); return false; } // Delete attachment files from storage (records cascade-deleted by EF) await _attachmentService.DeleteAttachmentsForPostAsync(postId); // Update thread reply count post.Thread.ReplyCount = Math.Max(0, post.Thread.ReplyCount - 1); // If we're deleting the last post, we need to find the new last post if (post.Thread.LastPostId == postId) { var newLastPost = await _context.ForumPosts .Where(p => p.ThreadId == post.ThreadId && p.Id != postId) .OrderByDescending(p => p.CreatedAt) .FirstOrDefaultAsync(); if (newLastPost != null) { post.Thread.LastPostId = newLastPost.Id; post.Thread.LastPostAt = newLastPost.CreatedAt; } } _context.ForumPosts.Remove(post); await _context.SaveChangesAsync(); _logger.LogInformation("Post {PostId} deleted", postId); return true; } public async Task> GetHighlightedPostIdsAsync(int threadId, int? totalPostCount = null, int minReactions = 5) { var resolvedTotalPostCount = totalPostCount ?? await _context.ForumPosts .AsNoTracking() .CountAsync(p => p.ThreadId == threadId); if (resolvedTotalPostCount <= 0) return []; var topCount = Math.Max(1, (int)Math.Ceiling(resolvedTotalPostCount * 0.1)); var cacheKey = $"forum:highlighted-posts:{threadId}:{resolvedTotalPostCount}:{minReactions}"; var cached = await _cache.GetOrCreateAsync(cacheKey, async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60); entry.SlidingExpiration = TimeSpan.FromSeconds(20); var threadPostIds = _context.ForumPosts .AsNoTracking() .Where(p => p.ThreadId == threadId) .Select(p => p.Id); var highlighted = await _context.Set() .AsNoTracking() .Where(r => r.EntityType == ReactionEntityType.ForumPost && threadPostIds.Contains(r.EntityId)) .GroupBy(r => r.EntityId) .Select(g => new { PostId = g.Key, Count = g.Count() }) .Where(x => x.Count >= minReactions) .OrderByDescending(x => x.Count) .ThenBy(x => x.PostId) .Take(topCount) .Select(x => x.PostId) .ToListAsync(); highlighted.Sort(); return highlighted; }); return cached ?? []; } } }