using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.Models; using Nuuru.Server.Models.Booru; using System.Security.Claims; namespace Nuuru.Server.Services { public interface IModerationService { Task DeletePostAsync(int postId, ClaimsPrincipal moderator, string? reason = null); Task DeleteCommentAsync(int commentId, ClaimsPrincipal moderator, string? reason = null); Task<(bool Success, string? Error)> EditPostTagsAsync(int postId, IEnumerable tags, ClaimsPrincipal moderator); Task BanUserAsync(Guid userId, ClaimsPrincipal moderator, string? reason = null, DateTime? until = null, BanZone zone = BanZone.Sitewide); Task UnbanUserAsync(Guid userId, ClaimsPrincipal moderator, BanZone? zone = null); Task<(IEnumerable Items, int TotalCount)> GetModerationLogAsync(int page = 1, int pageSize = 50); Task<(IEnumerable Items, int TotalCount)> GetUserModerationLogAsync(Guid userId, int page = 1, int pageSize = 50); Task ApprovePostAsync(int postId, ClaimsPrincipal moderator); Task RejectPostAsync(int postId, ClaimsPrincipal moderator, string reason); Task<(IEnumerable Items, int TotalCount)> GetPendingPostsAsync(int page = 1, int pageSize = 20); Task LockCommentsAsync(int postId, bool locked, ClaimsPrincipal moderator, string? reason = null); Task FeaturePostAsync(int postId, bool featured, ClaimsPrincipal moderator); Task RestorePostAsync(int postId, ClaimsPrincipal admin); Task PermanentlyDeletePostAsync(int postId, ClaimsPrincipal admin); Task<(IEnumerable Items, int TotalCount)> GetTrashedPostsAsync(int page = 1, int pageSize = 20); Task GetTrashedCountAsync(); } public class ModerationService : IModerationService { private readonly ApplicationDbContext _context; private readonly UserManager _userManager; private readonly IBanService _banService; private readonly ITagService _tagService; private readonly IPostService _postService; private readonly INotificationService _notificationService; private readonly ILogger _logger; public ModerationService( ApplicationDbContext context, UserManager userManager, IBanService banService, ITagService tagService, IPostService postService, INotificationService notificationService, ILogger logger) { _context = context; _userManager = userManager; _banService = banService; _tagService = tagService; // Used by EditPostTagsAsync _postService = postService; _notificationService = notificationService; _logger = logger; } public async Task DeletePostAsync(int postId, ClaimsPrincipal moderator, string? reason = null) { var moderatorUser = await _userManager.GetUserAsync(moderator); if (moderatorUser == null) return false; // Load post with uploader for notification var post = await _context.BooruPosts .Include(p => p.Uploader) .FirstOrDefaultAsync(p => p.Id == postId); if (post == null) return false; var postOwnerId = post.Uploader.Id; // Log the moderation action first var action = new ModerationAction { Action = "TrashPost", TargetType = "Post", TargetId = postId.ToString(), Reason = reason, Moderator = moderatorUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); // Move to trash instead of permanent deletion var result = await _postService.TrashPostAsync(postId, moderatorUser.Id, reason); if (result) { // Notify the post owner if (reason != null) { await _notificationService.CreatePostTrashedNotificationAsync(postId, postOwnerId, moderatorUser.Id, reason); } _logger.LogInformation("Moderator {ModeratorId} trashed post {PostId}", moderatorUser.Id, postId); } return result; } public async Task DeleteCommentAsync(int commentId, ClaimsPrincipal moderator, string? reason = null) { var comment = await _context.BooruComments.FindAsync(commentId); if (comment == null) return false; var moderatorUser = await _userManager.GetUserAsync(moderator); if (moderatorUser == null) return false; // Log the moderation action var action = new ModerationAction { Action = "DeleteComment", TargetType = "Comment", TargetId = commentId.ToString(), Reason = reason, Moderator = moderatorUser }; _context.ModerationActions.Add(action); _context.BooruComments.Remove(comment); await _context.SaveChangesAsync(); _logger.LogInformation("Moderator {ModeratorId} deleted comment {CommentId}", moderatorUser.Id, commentId); return true; } public async Task<(bool Success, string? Error)> EditPostTagsAsync(int postId, IEnumerable tags, ClaimsPrincipal moderator) { var post = await _context.BooruPosts .Include(p => p.PostTags) .ThenInclude(pt => pt.Tag) .FirstOrDefaultAsync(p => p.Id == postId); if (post == null) return (false, "Post not found"); var moderatorUser = await _userManager.GetUserAsync(moderator); if (moderatorUser == null) return (false, "Moderator not found"); // Capture current tag names for history var oldTags = string.Join(", ", post.PostTags.Select(pt => pt.Tag.Name)); // Use TagService to update tags await _tagService.UpdatePostTagsAsync(post, tags); // Log the moderation action var action = new ModerationAction { Action = "EditTags", TargetType = "Post", TargetId = postId.ToString(), Details = $"Old tags: {oldTags}; New tags: {string.Join(", ", tags)}", Moderator = moderatorUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); _logger.LogInformation("Moderator {ModeratorId} edited tags for post {PostId}", moderatorUser.Id, postId); return (true, null); } public async Task BanUserAsync(Guid userId, ClaimsPrincipal moderator, string? reason = null, DateTime? until = null, BanZone zone = BanZone.Sitewide) { var userToBan = await _userManager.FindByIdAsync(userId.ToString()); if (userToBan == null) return null; var moderatorUser = await _userManager.GetUserAsync(moderator); if (moderatorUser == null) return null; // Create ban using BanService var ban = await _banService.CreateBanAsync(userId, reason ?? "No reason provided", until, zone, moderatorUser); // Log the moderation action var action = new ModerationAction { Action = "BanUser", TargetType = "User", TargetId = userToBan.UserName ?? userId.ToString(), Reason = reason, Details = $"Zone: {zone}; {(until.HasValue ? $"Until: {until.Value:yyyy-MM-dd HH:mm:ss}" : "Permanent")}", Moderator = moderatorUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); _logger.LogInformation("Moderator {ModeratorId} banned user {UserId} in zone {Zone}", moderatorUser.Id, userId, zone); return userToBan; } public async Task UnbanUserAsync(Guid userId, ClaimsPrincipal moderator, BanZone? zone = null) { var userToUnban = await _userManager.FindByIdAsync(userId.ToString()); if (userToUnban == null) return null; var moderatorUser = await _userManager.GetUserAsync(moderator); if (moderatorUser == null) return null; // Unban using BanService var result = await _banService.UnbanUserAsync(userId, zone); if (!result) return null; // Log the moderation action var action = new ModerationAction { Action = "UnbanUser", TargetType = "User", TargetId = userToUnban.UserName ?? userId.ToString(), Details = zone.HasValue ? $"Zone: {zone.Value}" : "All zones", Moderator = moderatorUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); _logger.LogInformation("Moderator {ModeratorId} unbanned user {UserId} from {ZoneInfo}", moderatorUser.Id, userId, zone.HasValue ? $"zone {zone.Value}" : "all zones"); return userToUnban; } public async Task<(IEnumerable Items, int TotalCount)> GetModerationLogAsync(int page = 1, int pageSize = 50) { var totalCount = await _context.ModerationActions.CountAsync(); var items = await _context.ModerationActions .Include(a => a.Moderator) .OrderByDescending(a => a.Timestamp) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return (items, totalCount); } public async Task<(IEnumerable Items, int TotalCount)> GetUserModerationLogAsync(Guid userId, int page = 1, int pageSize = 50) { var query = _context.ModerationActions .Where(a => a.TargetType == "User" && a.TargetId == userId.ToString()); var totalCount = await query.CountAsync(); var items = await query .Include(a => a.Moderator) .OrderByDescending(a => a.Timestamp) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return (items, totalCount); } public async Task ApprovePostAsync(int postId, ClaimsPrincipal moderator) { var post = await _context.BooruPosts .Include(p => p.Uploader) .FirstOrDefaultAsync(p => p.Id == postId); if (post == null) return false; if (post.IsApproved) return true; // Already approved var moderatorUser = await _userManager.GetUserAsync(moderator); if (moderatorUser == null) return false; post.IsApproved = true; post.ApprovedById = moderatorUser.Id; post.ApprovedAt = DateTime.UtcNow; // Log the moderation action var action = new ModerationAction { Action = "ApprovePost", TargetType = "Post", TargetId = postId.ToString(), Moderator = moderatorUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); // Notify the post owner await _notificationService.CreatePostApprovedNotificationAsync(postId, post.Uploader.Id, moderatorUser.Id); _logger.LogInformation("Moderator {ModeratorId} approved post {PostId}", moderatorUser.Id, postId); return true; } public async Task RejectPostAsync(int postId, ClaimsPrincipal moderator, string reason) { var post = await _context.BooruPosts .Include(p => p.Uploader) .FirstOrDefaultAsync(p => p.Id == postId); if (post == null) return false; var moderatorUser = await _userManager.GetUserAsync(moderator); if (moderatorUser == null) return false; var postOwnerId = post.Uploader.Id; // Log the moderation action before deleting var action = new ModerationAction { Action = "RejectPost", TargetType = "Post", TargetId = postId.ToString(), Reason = reason, Moderator = moderatorUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); // Move to trash instead of permanent deletion var result = await _postService.TrashPostAsync(postId, moderatorUser.Id, reason); if (result) { // Notify the post owner with the rejection reason await _notificationService.CreatePostRejectedNotificationAsync(postId, postOwnerId, moderatorUser.Id, reason); _logger.LogInformation("Moderator {ModeratorId} rejected post {PostId} with reason: {Reason}", moderatorUser.Id, postId, reason); } return result; } public async Task LockCommentsAsync(int postId, bool locked, ClaimsPrincipal moderator, string? reason = null) { var post = await _context.BooruPosts.FindAsync(postId); if (post == null) return false; var moderatorUser = await _userManager.GetUserAsync(moderator); if (moderatorUser == null) return false; post.CommentsLocked = locked; // Log the moderation action var action = new ModerationAction { Action = locked ? "LockComments" : "UnlockComments", TargetType = "Post", TargetId = postId.ToString(), Reason = reason, Moderator = moderatorUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); _logger.LogInformation("Moderator {ModeratorId} {Action} comments on post {PostId}", moderatorUser.Id, locked ? "locked" : "unlocked", postId); return true; } public async Task FeaturePostAsync(int postId, bool featured, ClaimsPrincipal moderator) { var post = await _context.BooruPosts.FindAsync(postId); if (post == null) return false; var moderatorUser = await _userManager.GetUserAsync(moderator); if (moderatorUser == null) return false; if (featured) { // Unfeature any currently featured post var currentlyFeatured = await _context.BooruPosts .Where(p => p.IsFeatured) .ToListAsync(); foreach (var fp in currentlyFeatured) { fp.IsFeatured = false; fp.FeaturedAt = null; } post.IsFeatured = true; post.FeaturedAt = DateTime.UtcNow; } else { post.IsFeatured = false; post.FeaturedAt = null; } // Log the moderation action var action = new ModerationAction { Action = featured ? "FeaturePost" : "UnfeaturePost", TargetType = "Post", TargetId = postId.ToString(), Moderator = moderatorUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); _logger.LogInformation("Moderator {ModeratorId} {Action} post {PostId}", moderatorUser.Id, featured ? "featured" : "unfeatured", postId); return true; } public async Task<(IEnumerable Items, int TotalCount)> GetPendingPostsAsync(int page = 1, int pageSize = 20) { var baseQuery = _context.BooruPosts .Where(p => !p.IsApproved && !p.IsTrashed); var totalCount = await baseQuery.CountAsync(); var items = await baseQuery .OrderBy(p => p.UploadedAt) // Oldest first .Skip((page - 1) * pageSize) .Take(pageSize) .Include(p => p.Uploader) .Include(p => p.PostTags) .ThenInclude(pt => pt.Tag) .ThenInclude(t => t.Category) .AsSplitQuery() .ToListAsync(); return (items, totalCount); } public async Task RestorePostAsync(int postId, ClaimsPrincipal admin) { var adminUser = await _userManager.GetUserAsync(admin); if (adminUser == null) return false; // Load post with uploader before restoring (restore clears trash fields) var post = await _context.BooruPosts .Include(p => p.Uploader) .FirstOrDefaultAsync(p => p.Id == postId); var postOwnerId = post?.Uploader.Id; var result = await _postService.RestorePostAsync(postId); if (result) { var action = new ModerationAction { Action = "RestorePost", TargetType = "Post", TargetId = postId.ToString(), Moderator = adminUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); // Notify the post owner if (postOwnerId.HasValue) { await _notificationService.CreatePostRestoredNotificationAsync(postId, postOwnerId.Value, adminUser.Id); } _logger.LogInformation("Admin {AdminId} restored post {PostId} from trash", adminUser.Id, postId); } return result; } public async Task PermanentlyDeletePostAsync(int postId, ClaimsPrincipal admin) { var adminUser = await _userManager.GetUserAsync(admin); if (adminUser == null) return false; var action = new ModerationAction { Action = "PermanentlyDeletePost", TargetType = "Post", TargetId = postId.ToString(), Moderator = adminUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); var result = await _postService.DeletePostAsync(postId); if (result) { _logger.LogInformation("Admin {AdminId} permanently deleted post {PostId}", adminUser.Id, postId); } return result; } public async Task<(IEnumerable Items, int TotalCount)> GetTrashedPostsAsync(int page = 1, int pageSize = 20) { return await _postService.GetTrashedPostsAsync(page, pageSize); } public async Task GetTrashedCountAsync() { return await _postService.GetTrashedCountAsync(); } } }