using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.DTOs.Activity; using Nuuru.Server.DTOs.Booru; using Nuuru.Server.Extensions; using Nuuru.Server.Models; using Nuuru.Server.Services.Search; namespace Nuuru.Server.Services { public interface IActivityService { Task> GetRecentCommentGroupsAsync(int groupCount = 5, int commentsPerGroup = 3); Task> GetRecentForumPostGroupsAsync(Guid? userId = null, int groupCount = 5, int postsPerGroup = 3); Task> GetRecentForumThreadsAsync(Guid? userId = null, int count = 10); Task> GetRecentNewsThreadsAsync(Guid? userId = null, int count = 3); } public class ActivityService : IActivityService { private readonly ApplicationDbContext _context; private readonly IUserBadgeService _userBadgeService; private readonly IDefaultQueryFilterService _defaultQueryFilterService; private readonly ISiteSettingsService _siteSettings; public ActivityService( ApplicationDbContext context, IUserBadgeService userBadgeService, IDefaultQueryFilterService defaultQueryFilterService, ISiteSettingsService siteSettings) { _context = context; _userBadgeService = userBadgeService; _defaultQueryFilterService = defaultQueryFilterService; _siteSettings = siteSettings; } public async Task> GetRecentCommentGroupsAsync(int groupCount = 5, int commentsPerGroup = 3) { // Build allowed post IDs based on default search query filters var allowedPostIds = await _defaultQueryFilterService .ApplyDefaultFiltersAsync(_context.BooruPosts.AsQueryable()); var allowedPostIdQuery = allowedPostIds.Select(p => p.Id); // Get recent comments with user and post info var recentComments = await _context.BooruComments .Where(c => allowedPostIdQuery.Contains(c.PostId)) .Include(c => c.User) .Include(c => c.Post) .ThenInclude(p => p.Uploader) .OrderByDescending(c => c.CreatedAt) .Take(groupCount * commentsPerGroup * 4) // Fetch extra to account for grouping .AsSplitQuery() .ToListAsync(); var postIds = recentComments.Select(c => c.PostId).Distinct().ToList(); var commentCounts = await _context.BooruComments .Where(c => postIds.Contains(c.PostId)) .GroupBy(c => c.PostId) .Select(g => new { PostId = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.PostId, x => x.Count); // Fetch display info for all users involved var userIds = recentComments.Select(c => c.UserId) .Concat(recentComments.Where(c => c.Post.Uploader != null).Select(c => c.Post.Uploader!.Id)) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(userIds); var groups = recentComments .GroupBy(c => c.PostId) .Take(groupCount) .Select(g => { var post = g.First().Post; UserDisplayInfo? uploaderInfo = null; if (post.Uploader != null) displayInfoMap.TryGetValue(post.Uploader.Id, out uploaderInfo); return new RecentCommentGroupDto { PostId = g.Key, ThumbnailUrl = $"/api/booru/posts/{post.Id}/thumbnail", Uploader = post.Uploader?.ToUploaderDto(uploaderInfo), CommentCount = commentCounts.GetValueOrDefault(g.Key, 0), Comments = g.OrderByDescending(c => c.CreatedAt) .Take(commentsPerGroup) .Select(c => { UserDisplayInfo? authorInfo = null; displayInfoMap.TryGetValue(c.UserId, out authorInfo); return new RecentCommentDto { Id = c.Id, ContentHtml = c.ContentHtml, CreatedAt = c.CreatedAt, Author = c.User.ToUploaderDto(authorInfo) }; }) .ToList() }; }) .ToList(); return groups; } private async Task> GetAllowedCategoryIdsAsync(Guid? userId) { var allClanCategoryIds = await _context.ClanForumCategories .Select(c => c.ForumCategoryId).ToHashSetAsync(); if (allClanCategoryIds.Count == 0) return []; // Hide all clan forum activity when clans are disabled var clansEnabled = await _siteSettings.GetBoolAsync("boints.enabled", false) && await _siteSettings.GetBoolAsync("clans.enabled", false); if (!clansEnabled) return allClanCategoryIds; var userClanCategoryIds = new HashSet(); if (userId.HasValue) { var userClanId = await _context.ClanMembers .Where(m => m.UserId == userId.Value) .Select(m => m.ClanId) .FirstOrDefaultAsync(); if (userClanId != 0) { userClanCategoryIds = await _context.ClanForumCategories .Where(c => c.ClanId == userClanId) .Select(c => c.ForumCategoryId) .ToHashSetAsync(); } } // Return clan category IDs that the user should NOT see allClanCategoryIds.ExceptWith(userClanCategoryIds); return allClanCategoryIds; } public async Task> GetRecentForumPostGroupsAsync(Guid? userId = null, int groupCount = 5, int postsPerGroup = 3) { var excludedCategoryIds = await GetAllowedCategoryIdsAsync(userId); const int forumPageSize = 20; var recentThreads = await _context.ForumThreads .AsNoTracking() .Where(t => !excludedCategoryIds.Contains(t.CategoryId)) .OrderByDescending(t => t.LastPostAt) .ThenByDescending(t => t.Id) .Take(groupCount) .Select(t => new RecentForumPostThreadRow { ThreadId = t.Id, ThreadTitle = t.Title, CategorySlug = t.Category.Slug, CategoryColor = t.Category.Color, TotalPostCount = t.ReplyCount + (t.FirstPostId != null ? 1 : 0) }) .ToListAsync(); if (recentThreads.Count == 0) { return []; } var recentPostsByThreadId = new Dictionary>(recentThreads.Count); foreach (var thread in recentThreads) { var posts = await _context.ForumPosts .AsNoTracking() .Where(p => p.ThreadId == thread.ThreadId) .OrderByDescending(p => p.CreatedAt) .ThenByDescending(p => p.Id) .Take(postsPerGroup) .Select(p => new RecentForumPostRow { ThreadId = p.ThreadId, Id = p.Id, ContentHtml = p.ContentHtml, CreatedAt = p.CreatedAt, AuthorId = p.AuthorId, AuthorUserName = p.Author.UserName ?? string.Empty, AuthorAvatarStorageIdentifier = p.Author.AvatarStorageIdentifier, AuthorBackgroundImageStorageIdentifier = p.Author.BackgroundImageStorageIdentifier, AuthorStatus = p.Author.Status, AuthorForcedDisplayName = p.Author.ForcedDisplayName, AuthorForcedDisplayNameUntil = p.Author.ForcedDisplayNameUntil }) .ToListAsync(); if (posts.Count > 0) { for (var i = 0; i < posts.Count; i++) { var zeroBasedPosition = Math.Max(0, thread.TotalPostCount - i - 1); posts[i].Page = (zeroBasedPosition / forumPageSize) + 1; } recentPostsByThreadId[thread.ThreadId] = posts; } } if (recentPostsByThreadId.Count == 0) { return []; } // Fetch display info for all authors var authorIds = recentPostsByThreadId.Values .SelectMany(posts => posts) .Select(p => p.AuthorId) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(authorIds); var now = DateTime.UtcNow; var groups = recentThreads .Where(thread => recentPostsByThreadId.ContainsKey(thread.ThreadId)) .Select(thread => { var posts = recentPostsByThreadId[thread.ThreadId]; return new RecentForumPostGroupDto { ThreadId = thread.ThreadId, ThreadTitle = thread.ThreadTitle, CategorySlug = thread.CategorySlug, CategoryColor = thread.CategoryColor, Posts = posts .Select(p => { UserDisplayInfo? authorInfo = null; displayInfoMap.TryGetValue(p.AuthorId, out authorInfo); return new RecentForumPostDto { Id = p.Id, ContentHtml = p.ContentHtml, CreatedAt = p.CreatedAt, Author = BuildRecentForumPostAuthor(p, authorInfo, now), Page = p.Page }; }) .ToList() }; }) .ToList(); return groups; } public async Task> GetRecentForumThreadsAsync(Guid? userId = null, int count = 10) { var excludedCategoryIds = await GetAllowedCategoryIdsAsync(userId); var threads = await _context.ForumThreads .Include(t => t.Author) .Include(t => t.LastPost) .ThenInclude(p => p!.Author) .Include(t => t.Category) .Where(t => !excludedCategoryIds.Contains(t.CategoryId)) .OrderByDescending(t => t.LastPostAt) .Take(count) .AsSplitQuery() .ToListAsync(); // Fetch display info for all thread and last post authors var authorIds = threads.Select(t => t.AuthorId) .Concat(threads.Where(t => t.LastPost != null).Select(t => t.LastPost!.AuthorId)) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(authorIds); return threads.Select(t => { UserDisplayInfo? authorInfo = null; displayInfoMap.TryGetValue(t.AuthorId, out authorInfo); UserDisplayInfo? lastPostAuthorInfo = null; if (t.LastPost != null) displayInfoMap.TryGetValue(t.LastPost.AuthorId, out lastPostAuthorInfo); return new RecentForumThreadDto { Id = t.Id, Title = t.Title, IsPinned = t.IsPinned, IsLocked = t.IsLocked, CategorySlug = t.Category.Slug, CategoryName = t.Category.Name, CategoryColor = t.Category.Color, CreatedAt = t.CreatedAt, LastPostAt = t.LastPostAt, Author = t.Author.ToUploaderDto(authorInfo), LastPostAuthor = t.LastPost?.Author?.ToUploaderDto(lastPostAuthorInfo), LastPostId = t.LastPostId, ReplyCount = t.ReplyCount, ViewCount = t.ViewCount }; }).ToList(); } public async Task> GetRecentNewsThreadsAsync(Guid? userId = null, int count = 3) { var excludedCategoryIds = await GetAllowedCategoryIdsAsync(userId); var threads = await _context.ForumThreads .Include(t => t.Author) .Include(t => t.Category) .Where(t => t.Category.Slug == "news" && !excludedCategoryIds.Contains(t.CategoryId)) .OrderByDescending(t => t.CreatedAt) .Take(count) .AsSplitQuery() .ToListAsync(); var authorIds = threads.Select(t => t.AuthorId).Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(authorIds); return threads.Select(t => { UserDisplayInfo? authorInfo = null; displayInfoMap.TryGetValue(t.AuthorId, out authorInfo); return new RecentForumThreadDto { Id = t.Id, Title = t.Title, IsPinned = t.IsPinned, IsLocked = t.IsLocked, CategorySlug = t.Category.Slug, CategoryName = t.Category.Name, CategoryColor = t.Category.Color, CreatedAt = t.CreatedAt, LastPostAt = t.LastPostAt, Author = t.Author.ToUploaderDto(authorInfo), LastPostAuthor = null, LastPostId = t.LastPostId, ReplyCount = t.ReplyCount, ViewCount = t.ViewCount }; }).ToList(); } private static UploaderDto BuildRecentForumPostAuthor(RecentForumPostRow post, UserDisplayInfo? displayInfo, DateTime now) { var activeForcedDisplayName = post.AuthorForcedDisplayName != null && post.AuthorForcedDisplayNameUntil > now ? post.AuthorForcedDisplayName : null; return new UploaderDto { Id = post.AuthorId, UserName = post.AuthorUserName, AvatarUrl = ApplicationUser.GetAvatarUrl(post.AuthorUserName, post.AuthorAvatarStorageIdentifier), BackgroundImageUrl = ApplicationUser.GetBackgroundImageUrl(post.AuthorUserName, post.AuthorBackgroundImageStorageIdentifier), RoleColor = displayInfo?.RoleColor, Status = post.AuthorStatus, Badges = displayInfo?.Badges ?? [], ActiveBanZones = displayInfo?.ActiveBanZones?.Select(z => z.ToString()).ToList() ?? [], ForcedDisplayName = (displayInfo?.BointsEnabled ?? false) ? activeForcedDisplayName : null, ClanId = displayInfo?.ClanId, ClanTag = displayInfo?.ClanTag, ClanColor = displayInfo?.ClanColor, ClanBadgeUrl = displayInfo?.ClanBadgeUrl }; } private sealed class RecentForumPostThreadRow { public int ThreadId { get; init; } public string ThreadTitle { get; init; } = string.Empty; public string CategorySlug { get; init; } = string.Empty; public string CategoryColor { get; init; } = string.Empty; public int TotalPostCount { get; init; } } private sealed class RecentForumPostRow { public int ThreadId { get; init; } public int Id { get; init; } public string ContentHtml { get; init; } = string.Empty; public DateTime CreatedAt { get; init; } public Guid AuthorId { get; init; } public string AuthorUserName { get; init; } = string.Empty; public string? AuthorAvatarStorageIdentifier { get; init; } public string? AuthorBackgroundImageStorageIdentifier { get; init; } public string? AuthorStatus { get; init; } public string? AuthorForcedDisplayName { get; init; } public DateTime? AuthorForcedDisplayNameUntil { get; init; } public int Page { get; set; } } } }