using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.DTOs.Moderation; using Nuuru.Server.Extensions; using Nuuru.Server.Models; using System.Security.Claims; namespace Nuuru.Server.Services { public interface IReportService { Task CreateReportAsync(Guid reporterId, ReportTargetType targetType, string targetId, string reason); Task HasPendingReportAsync(Guid reporterId, ReportTargetType targetType, string targetId); Task TargetExistsAsync(ReportTargetType targetType, string targetId); Task<(IEnumerable Items, int TotalCount)> GetReportsAsync(ReportStatus? status, int page, int pageSize); Task GetReportByIdAsync(Guid reportId); Task ResolveReportAsync(Guid reportId, ClaimsPrincipal moderator, string? note = null); Task DismissReportAsync(Guid reportId, ClaimsPrincipal moderator, string? note = null); Task GetPendingReportCountAsync(); Task GetTargetPreviewAsync(ReportTargetType targetType, string targetId); } public class ReportService : IReportService { private readonly ApplicationDbContext _context; private readonly UserManager _userManager; private readonly INotificationService _notificationService; private readonly ILogger _logger; private readonly IBointsService _bointsService; public ReportService( ApplicationDbContext context, UserManager userManager, INotificationService notificationService, IBointsService bointsService, ILogger logger) { _context = context; _userManager = userManager; _notificationService = notificationService; _bointsService = bointsService; _logger = logger; } public async Task CreateReportAsync(Guid reporterId, ReportTargetType targetType, string targetId, string reason) { var report = new Report { ReporterId = reporterId, TargetType = targetType, TargetId = targetId, Reason = reason, Status = ReportStatus.Pending, CreatedAt = DateTime.UtcNow }; _context.Reports.Add(report); await _context.SaveChangesAsync(); _logger.LogInformation("User {UserId} created report for {TargetType} {TargetId}", reporterId, targetType, targetId); return report; } public async Task HasPendingReportAsync(Guid reporterId, ReportTargetType targetType, string targetId) { return await _context.Reports.AnyAsync(r => r.ReporterId == reporterId && r.TargetType == targetType && r.TargetId == targetId && r.Status == ReportStatus.Pending); } public async Task TargetExistsAsync(ReportTargetType targetType, string targetId) { return targetType switch { ReportTargetType.Post => int.TryParse(targetId, out var postId) && await _context.BooruPosts.AnyAsync(p => p.Id == postId), ReportTargetType.Comment => int.TryParse(targetId, out var commentId) && await _context.BooruComments.AnyAsync(c => c.Id == commentId), ReportTargetType.User => Guid.TryParse(targetId, out _) ? await _userManager.FindByIdAsync(targetId) != null : await _userManager.FindByNameAsync(targetId) != null, ReportTargetType.ForumPost => int.TryParse(targetId, out var forumPostId) && await _context.ForumPosts.AnyAsync(p => p.Id == forumPostId), ReportTargetType.Message => int.TryParse(targetId, out var messageId) && await _context.Messages.AnyAsync(m => m.Id == messageId), _ => false }; } public async Task<(IEnumerable Items, int TotalCount)> GetReportsAsync(ReportStatus? status, int page, int pageSize) { var baseQuery = _context.Reports.AsQueryable(); if (status.HasValue) { baseQuery = baseQuery.Where(r => r.Status == status.Value); } var totalCount = await baseQuery.CountAsync(); var items = await baseQuery .OrderByDescending(r => r.Status == ReportStatus.Pending) .ThenByDescending(r => r.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Include(r => r.Reporter) .Include(r => r.Moderator) .ToListAsync(); return (items, totalCount); } public async Task GetReportByIdAsync(Guid reportId) { return await _context.Reports .Include(r => r.Reporter) .Include(r => r.Moderator) .FirstOrDefaultAsync(r => r.Id == reportId); } public async Task ResolveReportAsync(Guid reportId, ClaimsPrincipal moderator, string? note = null) { var report = await _context.Reports .Include(r => r.Reporter) .FirstOrDefaultAsync(r => r.Id == reportId); if (report == null) return null; if (report.Status != ReportStatus.Pending) return report; // Already handled var moderatorUser = await _userManager.GetUserAsync(moderator); if (moderatorUser == null) return null; report.Status = ReportStatus.Resolved; report.ResolvedAt = DateTime.UtcNow; report.ResolutionNote = note; report.ModeratorId = moderatorUser.Id; report.Moderator = moderatorUser; // Log the moderation action var action = new ModerationAction { Action = "ResolveReport", TargetType = "Report", TargetId = reportId.ToString(), Reason = note, Details = $"Reported {report.TargetType} {report.TargetId}", Moderator = moderatorUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); // Notify the reporter that their report was resolved await _notificationService.CreateReportResolvedNotificationAsync( report.ReporterId, moderatorUser.Id, report.TargetType.ToString(), report.TargetId, note); // Credit reporter for valid report (skip if moderator resolved their own report) if (report.ReporterId != moderatorUser.Id) { await _bointsService.CreditAsync(report.ReporterId, BointsReason.ReportResolved, 20, sourcePostId: int.TryParse(report.TargetId, out var tid) ? tid : null); } _logger.LogInformation("Moderator {ModeratorId} resolved report {ReportId}", moderatorUser.Id, reportId); return report; } public async Task DismissReportAsync(Guid reportId, ClaimsPrincipal moderator, string? note = null) { var report = await _context.Reports .Include(r => r.Reporter) .FirstOrDefaultAsync(r => r.Id == reportId); if (report == null) return null; if (report.Status != ReportStatus.Pending) return report; // Already handled var moderatorUser = await _userManager.GetUserAsync(moderator); if (moderatorUser == null) return null; report.Status = ReportStatus.Dismissed; report.ResolvedAt = DateTime.UtcNow; report.ResolutionNote = note; report.ModeratorId = moderatorUser.Id; report.Moderator = moderatorUser; // Log the moderation action var action = new ModerationAction { Action = "DismissReport", TargetType = "Report", TargetId = reportId.ToString(), Reason = note, Details = $"Reported {report.TargetType} {report.TargetId}", Moderator = moderatorUser }; _context.ModerationActions.Add(action); await _context.SaveChangesAsync(); // Notify the reporter that their report was dismissed await _notificationService.CreateReportDismissedNotificationAsync( report.ReporterId, moderatorUser.Id, report.TargetType.ToString(), report.TargetId, note); // Penalty for repeat false reporters: -20 if 3+ dismissed in 30 days (skip if self-dismissed) if (report.ReporterId != moderatorUser.Id) { var cutoff = DateTime.UtcNow.AddDays(-30); var dismissedCount = await _context.Reports .CountAsync(r => r.ReporterId == report.ReporterId && r.Status == ReportStatus.Dismissed && r.ResolvedAt >= cutoff); if (dismissedCount >= 3) { await _bointsService.DebitAsync(report.ReporterId, 20, BointsReason.FalseReport); } } _logger.LogInformation("Moderator {ModeratorId} dismissed report {ReportId}", moderatorUser.Id, reportId); return report; } public async Task GetPendingReportCountAsync() { return await _context.Reports.CountAsync(r => r.Status == ReportStatus.Pending); } public async Task GetTargetPreviewAsync(ReportTargetType targetType, string targetId) { switch (targetType) { case ReportTargetType.Post: if (int.TryParse(targetId, out var postId)) { var post = await _context.BooruPosts .Include(p => p.Uploader) .FirstOrDefaultAsync(p => p.Id == postId); if (post != null) { return new ReportTargetPreviewDto { ThumbnailUrl = $"/api/booru/posts/{post.Id}/thumbnail", UserName = post.Uploader?.UserName }; } } break; case ReportTargetType.Comment: if (int.TryParse(targetId, out var commentId)) { var comment = await _context.BooruComments .Include(c => c.User) .FirstOrDefaultAsync(c => c.Id == commentId); if (comment != null) { return new ReportTargetPreviewDto { UserName = comment.User?.UserName, Excerpt = comment.ContentRaw.Length > 200 ? comment.ContentRaw.Substring(0, 200) + "..." : comment.ContentRaw, PostId = comment.PostId }; } } break; case ReportTargetType.User: ApplicationUser? user = null; if (Guid.TryParse(targetId, out _)) { user = await _userManager.FindByIdAsync(targetId); } else { user = await _userManager.FindByNameAsync(targetId); } if (user != null) { return new ReportTargetPreviewDto { UserName = user.UserName }; } break; case ReportTargetType.ForumPost: if (int.TryParse(targetId, out var forumPostId)) { var forumPost = await _context.ForumPosts .Include(p => p.Author) .Include(p => p.Thread) .ThenInclude(t => t.Category) .FirstOrDefaultAsync(p => p.Id == forumPostId); if (forumPost != null) { // Compute the page number for this post within its thread var positionInThread = await _context.ForumPosts .CountAsync(p => p.ThreadId == forumPost.ThreadId && p.Id <= forumPost.Id); var page = (int)Math.Ceiling(positionInThread / 20.0); return new ReportTargetPreviewDto { UserName = forumPost.Author?.UserName, Excerpt = forumPost.ContentRaw.Length > 200 ? forumPost.ContentRaw.Substring(0, 200) + "..." : forumPost.ContentRaw, ThreadId = forumPost.ThreadId, CategorySlug = forumPost.Thread?.Category?.Slug, Page = page }; } } break; case ReportTargetType.Message: if (int.TryParse(targetId, out var msgId)) { var message = await _context.Messages .Include(m => m.Author) .FirstOrDefaultAsync(m => m.Id == msgId); if (message != null) { return new ReportTargetPreviewDto { UserName = message.Author?.UserName, Excerpt = message.ContentRaw.Length > 200 ? message.ContentRaw.Substring(0, 200) + "..." : message.ContentRaw, ConversationId = message.ConversationId.ToString() }; } } break; } return null; } } }