using System.Collections.Concurrent; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.DTOs; using Nuuru.Server.DTOs.Admin; using Nuuru.Server.DTOs.Moderation; using Nuuru.Server.Models; using Nuuru.Server.Models.Booru; using Nuuru.Server.Services.BBCode; using Nuuru.Server.Services.Storage; namespace Nuuru.Server.Services { public interface IAdminService { Task> SearchUsersAsync(string? search, string? role, int page, int pageSize); Task GetUserByIdAsync(Guid userId); Task<(bool Success, string? Error)> ChangeUserPasswordAsync(Guid userId, string newPassword, Guid adminId); Task<(bool Success, string? Error)> UpdateUserProfileAsync(Guid userId, string? status, string? biography, bool? isBabyMode, Guid adminId); Task<(bool Success, UserAdminDto? User, string? Error)> CreateUserAsync(string userName, string password, Guid adminId); Task<(bool Success, NukePreviewDto? Preview, string? Error)> GetNukePreviewAsync(Guid userId, Guid adminId); Task<(bool Success, string? Error)> NukeUserAsync(Guid userId, Guid adminId, string token); Task GetActivityStatsAsync(ActivityStatsQueryDto query); } public class AdminService : IAdminService { private static readonly ConcurrentDictionary _nukeTokens = new(); private static readonly TimeSpan NukeTokenLifetime = TimeSpan.FromMinutes(5); private const int DefaultActivityRangeDays = 30; private const int MaxActivityRangeDays = 366; private readonly ApplicationDbContext _context; private readonly UserManager _userManager; private readonly ITokenService _tokenService; private readonly IBBCodeService _bbCodeService; private readonly IFileStorageService _fileStorageService; private readonly ILogger _logger; public AdminService( ApplicationDbContext context, UserManager userManager, ITokenService tokenService, IBBCodeService bbCodeService, IFileStorageService fileStorageService, ILogger logger) { _context = context; _userManager = userManager; _tokenService = tokenService; _bbCodeService = bbCodeService; _fileStorageService = fileStorageService; _logger = logger; } public async Task> SearchUsersAsync(string? search, string? role, int page, int pageSize) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); var query = _context.Users.AsQueryable(); query = query.Where(u => !u.IsSystemAccount); if (!string.IsNullOrWhiteSpace(search)) { var searchLower = search.ToLower(); query = query.Where(u => u.UserName!.ToLower().Contains(searchLower)); } if (!string.IsNullOrWhiteSpace(role)) { query = query.Where(u => _context.UserRoles .Any(ur => ur.UserId == u.Id && _context.Roles.Any(r => r.Id == ur.RoleId && r.Name == role))); } var totalCount = await query.CountAsync(); var users = await query .OrderBy(u => u.UserName) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); var userDtos = new List(); foreach (var user in users) { var dto = await MapUserToAdminDtoAsync(user); userDtos.Add(dto); } return new PagedResult { Items = userDtos, TotalCount = totalCount, Page = page, PageSize = pageSize }; } public async Task GetUserByIdAsync(Guid userId) { var user = await _context.Users.FindAsync(userId); if (user == null) return null; return await MapUserToAdminDtoAsync(user); } public async Task<(bool Success, string? Error)> ChangeUserPasswordAsync(Guid userId, string newPassword, Guid adminId) { var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return (false, "User not found"); if (user.IsSystemAccount) return (false, "Cannot modify system accounts"); // Validate password against configured password rules foreach (var validator in _userManager.PasswordValidators) { var validationResult = await validator.ValidateAsync(_userManager, user, newPassword); if (!validationResult.Succeeded) return (false, string.Join(", ", validationResult.Errors.Select(e => e.Description))); } // Set hash directly to bypass UserValidator (which rejects empty emails) user.PasswordHash = _userManager.PasswordHasher.HashPassword(user, newPassword); user.SecurityStamp = Guid.NewGuid().ToString(); await _tokenService.RevokeAllRefreshTokensForUserAsync(userId); var admin = await _context.Users.FindAsync(adminId); if (admin != null) { _context.ModerationActions.Add(new ModerationAction { Action = "ChangePassword", TargetType = "User", TargetId = user.UserName ?? userId.ToString(), Details = $"Password changed for user \"{user.UserName}\"", Moderator = admin }); } await _context.SaveChangesAsync(); _logger.LogInformation("Admin {AdminId} changed password for user {UserId}", adminId, userId); return (true, null); } public async Task<(bool Success, string? Error)> UpdateUserProfileAsync(Guid userId, string? status, string? biography, bool? isBabyMode, Guid adminId) { var user = await _context.Users.FindAsync(userId); if (user == null) return (false, "User not found"); if (user.IsSystemAccount) return (false, "Cannot modify system accounts"); if (status != null) user.Status = status; if (biography != null) { user.Biography = biography; user.BiographyHtml = _bbCodeService.Parse(biography); } if (isBabyMode.HasValue) user.IsBabyMode = isBabyMode.Value; var admin = await _context.Users.FindAsync(adminId); if (admin != null) { _context.ModerationActions.Add(new ModerationAction { Action = "UpdateProfile", TargetType = "User", TargetId = user.UserName ?? userId.ToString(), Details = $"Profile updated for user \"{user.UserName}\"", Moderator = admin }); } await _context.SaveChangesAsync(); _logger.LogInformation("Admin {AdminId} updated profile for user {UserId}", adminId, userId); return (true, null); } public async Task<(bool Success, UserAdminDto? User, string? Error)> CreateUserAsync(string userName, string password, Guid adminId) { var user = new ApplicationUser { UserName = userName, Status = "", Biography = "", DateCreated = DateTime.UtcNow, }; var result = await _userManager.CreateAsync(user, password); if (!result.Succeeded) return (false, null, string.Join(", ", result.Errors.Select(e => e.Description))); await _userManager.AddToRoleAsync(user, "User"); var admin = await _context.Users.FindAsync(adminId); if (admin != null) { _context.ModerationActions.Add(new ModerationAction { Action = "CreateUser", TargetType = "User", TargetId = user.UserName ?? user.Id.ToString(), Details = $"Account created for user \"{userName}\"", Moderator = admin }); await _context.SaveChangesAsync(); } _logger.LogInformation("Admin {AdminId} created user account {UserId} ({UserName})", adminId, user.Id, userName); var dto = await MapUserToAdminDtoAsync(user); return (true, dto, null); } public async Task<(bool Success, NukePreviewDto? Preview, string? Error)> GetNukePreviewAsync(Guid userId, Guid adminId) { if (userId == adminId) return (false, null, "Cannot nuke your own account"); var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return (false, null, "User not found"); if (user.IsSystemAccount) return (false, null, "Cannot nuke system accounts"); var posts = await _context.BooruPosts.CountAsync(p => p.UploaderId == userId); var comments = await _context.BooruComments.CountAsync(c => c.UserId == userId); var forumThreads = await _context.ForumThreads.CountAsync(t => t.AuthorId == userId); var forumPosts = await _context.ForumPosts.CountAsync(p => p.AuthorId == userId); var messages = await _context.Messages.CountAsync(m => m.AuthorId == userId); // Clean up expired tokens var expired = _nukeTokens.Where(kvp => kvp.Value.Expiry < DateTime.UtcNow).Select(kvp => kvp.Key).ToList(); foreach (var key in expired) _nukeTokens.TryRemove(key, out _); var token = Guid.NewGuid().ToString("N"); _nukeTokens[token] = (userId, adminId, DateTime.UtcNow.Add(NukeTokenLifetime)); return (true, new NukePreviewDto { Posts = posts, Comments = comments, ForumThreads = forumThreads, ForumPosts = forumPosts, Messages = messages, Token = token, }, null); } public async Task<(bool Success, string? Error)> NukeUserAsync(Guid userId, Guid adminId, string token) { // Validate token if (!_nukeTokens.TryRemove(token, out var tokenData)) return (false, "Invalid or expired nuke token. Please start the process again."); if (tokenData.Expiry < DateTime.UtcNow) return (false, "Nuke token has expired. Please start the process again."); if (tokenData.UserId != userId || tokenData.AdminId != adminId) return (false, "Token does not match this user/admin combination"); if (userId == adminId) return (false, "Cannot nuke your own account"); var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return (false, "User not found"); if (user.IsSystemAccount) return (false, "Cannot nuke system accounts"); var userName = user.UserName ?? "Unknown"; await using var transaction = await _context.Database.BeginTransactionAsync(); try { // 1. Log the nuke action before deleting anything (admin is moderator) var admin = await _context.Users.FindAsync(adminId); if (admin != null) { _context.ModerationActions.Add(new ModerationAction { Action = "NukeUser", TargetType = "User", TargetId = userName, Details = $"Nuked user \"{userName}\" (ID: {userId}) — all posts, comments, and account deleted", Moderator = admin }); await _context.SaveChangesAsync(); } // 2. Delete notifications (user's own) await _context.Notifications .Where(n => n.UserId == userId) .ExecuteDeleteAsync(); // 3. Nullify TriggeredByUserId on notifications triggered by this user await _context.Notifications .Where(n => n.TriggeredByUserId == userId) .ExecuteUpdateAsync(s => s.SetProperty(n => n.TriggeredByUserId, (Guid?)null)); // 4. Delete votes and favorites by user await _context.BooruPostVotes .Where(v => v.UserId == userId) .ExecuteDeleteAsync(); await _context.BooruPostFavorites .Where(f => f.UserId == userId) .ExecuteDeleteAsync(); // 5. Delete watches and reactions by user await _context.Watches .Where(w => w.UserId == userId) .ExecuteDeleteAsync(); await _context.Reactions .Where(r => r.UserId == userId) .ExecuteDeleteAsync(); // 6. Delete comment mentions on user's booru comments, then delete comments var userCommentIds = await _context.BooruComments .Where(c => c.UserId == userId) .Select(c => c.Id) .ToListAsync(); if (userCommentIds.Count > 0) { await _context.CommentMentions .Where(m => userCommentIds.Contains(m.CommentId)) .ExecuteDeleteAsync(); await _context.BooruComments .Where(c => c.UserId == userId) .ExecuteDeleteAsync(); } // 7. Handle posts by user — delete files, post tags, related data, then posts var userPosts = await _context.BooruPosts .Include(p => p.PostTags) .Where(p => p.UploaderId == userId) .ToListAsync(); if (userPosts.Count > 0) { var userPostIds = userPosts.Select(p => p.Id).ToList(); // Collect tag count decrements var tagCountUpdates = new Dictionary(); foreach (var post in userPosts) { if (post.PostTags != null) { foreach (var pt in post.PostTags) { tagCountUpdates[pt.TagId] = tagCountUpdates.GetValueOrDefault(pt.TagId) + 1; } } } // Delete physical files foreach (var post in userPosts) { try { await _fileStorageService.DeleteFileAsync(post.StorageIdentifier); } catch { /* best effort */ } if (post.ThumbnailPath != null) { try { await _fileStorageService.DeleteFileAsync(post.ThumbnailPath); } catch { /* best effort */ } } } // Delete comments and their mentions on user's posts var commentsOnUserPostIds = await _context.BooruComments .Where(c => userPostIds.Contains(c.PostId)) .Select(c => c.Id) .ToListAsync(); if (commentsOnUserPostIds.Count > 0) { await _context.CommentMentions .Where(m => commentsOnUserPostIds.Contains(m.CommentId)) .ExecuteDeleteAsync(); await _context.BooruComments .Where(c => userPostIds.Contains(c.PostId)) .ExecuteDeleteAsync(); } // Delete votes and favorites on user's posts await _context.BooruPostVotes .Where(v => userPostIds.Contains(v.PostId)) .ExecuteDeleteAsync(); await _context.BooruPostFavorites .Where(f => userPostIds.Contains(f.PostId)) .ExecuteDeleteAsync(); // Delete post tags await _context.Set() .Where(pt => userPostIds.Contains(pt.PostId)) .ExecuteDeleteAsync(); // Delete the posts await _context.BooruPosts .Where(p => userPostIds.Contains(p.Id)) .ExecuteDeleteAsync(); // Update tag counts foreach (var (tagId, count) in tagCountUpdates) { await _context.BooruTags .Where(t => t.Id == tagId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.PostCount, t => t.PostCount - count) .SetProperty(t => t.UpdatedAt, DateTime.UtcNow)); } } // 8. Forum — handle RESTRICT FKs var userThreadIds = await _context.ForumThreads .Where(t => t.AuthorId == userId) .Select(t => t.Id) .ToListAsync(); var userForumPostIds = await _context.ForumPosts .Where(p => p.AuthorId == userId) .Select(p => p.Id) .ToListAsync(); // Null out LastPostId/FirstPostId on any thread that references user's forum posts if (userForumPostIds.Count > 0) { await _context.ForumThreads .Where(t => t.LastPostId != null && userForumPostIds.Contains(t.LastPostId.Value)) .ExecuteUpdateAsync(s => s.SetProperty(t => t.LastPostId, (int?)null)); await _context.ForumThreads .Where(t => t.FirstPostId != null && userForumPostIds.Contains(t.FirstPostId.Value)) .ExecuteUpdateAsync(s => s.SetProperty(t => t.FirstPostId, (int?)null)); } // Null out LastPostId/FirstPostId on user's own threads if (userThreadIds.Count > 0) { await _context.ForumThreads .Where(t => userThreadIds.Contains(t.Id)) .ExecuteUpdateAsync(s => s .SetProperty(t => t.LastPostId, (int?)null) .SetProperty(t => t.FirstPostId, (int?)null)); } // Collect all forum post IDs to delete (user's posts + all posts in user's threads) var threadForumPostIds = userThreadIds.Count > 0 ? await _context.ForumPosts .Where(p => userThreadIds.Contains(p.ThreadId)) .Select(p => p.Id) .ToListAsync() : new List(); var allForumPostIds = userForumPostIds.Union(threadForumPostIds).ToList(); if (allForumPostIds.Count > 0) { await _context.ForumPostMentions .Where(m => allForumPostIds.Contains(m.ForumPostId)) .ExecuteDeleteAsync(); await _context.ForumPosts .Where(p => allForumPostIds.Contains(p.Id)) .ExecuteDeleteAsync(); } if (userThreadIds.Count > 0) { await _context.ForumThreads .Where(t => userThreadIds.Contains(t.Id)) .ExecuteDeleteAsync(); } // 9. Messages and Conversations — handle RESTRICT FKs var userMessageIds = await _context.Messages .Where(m => m.AuthorId == userId) .Select(m => m.Id) .ToListAsync(); if (userMessageIds.Count > 0) { await _context.MessageMentions .Where(mm => userMessageIds.Contains(mm.MessageId)) .ExecuteDeleteAsync(); await _context.Messages .Where(m => userMessageIds.Contains(m.Id)) .ExecuteDeleteAsync(); } // Delete conversations created by user (cascade deletes remaining messages and participants) var userConversationIds = await _context.Conversations .Where(c => c.CreatorId == userId) .Select(c => c.Id) .ToListAsync(); if (userConversationIds.Count > 0) { // Delete message mentions in those conversations first var convMessageIds = await _context.Messages .Where(m => userConversationIds.Contains(m.ConversationId)) .Select(m => m.Id) .ToListAsync(); if (convMessageIds.Count > 0) { await _context.MessageMentions .Where(mm => convMessageIds.Contains(mm.MessageId)) .ExecuteDeleteAsync(); } // Messages and participants cascade from conversation delete await _context.Messages .Where(m => userConversationIds.Contains(m.ConversationId)) .ExecuteDeleteAsync(); await _context.ConversationParticipants .Where(cp => userConversationIds.Contains(cp.ConversationId)) .ExecuteDeleteAsync(); await _context.Conversations .Where(c => userConversationIds.Contains(c.Id)) .ExecuteDeleteAsync(); } // Remove user's conversation participations in other conversations await _context.ConversationParticipants .Where(cp => cp.UserId == userId) .ExecuteDeleteAsync(); // 10. StoredFiles by user — RESTRICT FK await _context.StoredFiles .Where(f => f.UploaderId == userId) .ExecuteDeleteAsync(); // 11. Reports by user — CASCADE (handled by user delete), but clean up ModeratorId await _context.Reports .Where(r => r.ReporterId == userId) .ExecuteDeleteAsync(); // 12. UserIps, Bans, BanAppeals, RefreshTokens // Nullify BanAppeal.ModeratorId where this user reviewed appeals (ClientSetNull — no DB cascade) await _context.Set() .Where(ba => ba.ModeratorId == userId) .ExecuteUpdateAsync(s => s.SetProperty(ba => ba.ModeratorId, (Guid?)null)); await _context.Set() .Where(ba => ba.UserId == userId) .ExecuteDeleteAsync(); await _context.Bans .Where(b => EF.Property(b, "UserId") == userId) .ExecuteDeleteAsync(); await _context.UserIps .Where(u => EF.Property(u, "UserId") == userId) .ExecuteDeleteAsync(); await _context.RefreshTokens .Where(r => r.UserId == userId) .ExecuteDeleteAsync(); // 13. Nullify nullable FKs on other entities pointing to this user await _context.BooruPosts .Where(p => p.ApprovedById == userId) .ExecuteUpdateAsync(s => s.SetProperty(p => p.ApprovedById, (Guid?)null)); await _context.BooruPosts .Where(p => p.TrashedById == userId) .ExecuteUpdateAsync(s => s.SetProperty(p => p.TrashedById, (Guid?)null)); await _context.Reports .Where(r => r.ModeratorId == userId) .ExecuteUpdateAsync(s => s.SetProperty(r => r.ModeratorId, (Guid?)null)); // 14. Nullify audit log references to this user await _context.AuditLogs .Where(a => a.UserId == userId) .ExecuteUpdateAsync(s => s.SetProperty(a => a.UserId, (Guid?)null)); // 15. Delete moderation actions where this user was the moderator await _context.ModerationActions .Where(m => EF.Property(m, "ModeratorId") == userId) .ExecuteDeleteAsync(); // 16. Delete user avatar if (user.AvatarStorageIdentifier != null) { try { await _fileStorageService.DeleteFileAsync(user.AvatarStorageIdentifier); } catch { /* best effort */ } } // 17. Delete the user // Clear the change tracker — ExecuteDeleteAsync doesn't evict tracked entities, // so stale tracked Posts/PostTags from step 7's .Include() would cause // SaveChangesAsync to send duplicate cascade deletes and collide with the user delete. _context.ChangeTracker.Clear(); // Re-fetch the user so Identity can track and delete it cleanly user = (await _userManager.FindByIdAsync(userId.ToString()))!; var deleteResult = await _userManager.DeleteAsync(user); if (!deleteResult.Succeeded) { await transaction.RollbackAsync(); return (false, "Failed to delete user: " + string.Join(", ", deleteResult.Errors.Select(e => e.Description))); } await transaction.CommitAsync(); _logger.LogWarning("Admin {AdminId} nuked user {UserId} ({UserName})", adminId, userId, userName); return (true, null); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Failed to nuke user {UserId}", userId); return (false, "An error occurred while nuking the user"); } } public async Task GetActivityStatsAsync(ActivityStatsQueryDto query) { var now = DateTime.UtcNow; var hourAgo = now.AddHours(-1); var dayAgo = now.AddDays(-1); var weekAgo = now.AddDays(-7); var monthAgo = now.AddDays(-30); var today = DateOnly.FromDateTime(now); var (dateFrom, dateTo) = ResolveActivityRange(query, today); var rangeStartUtc = AsUtcStart(dateFrom); var rangeEndExclusiveUtc = AsUtcStart(dateTo.AddDays(1)); var booruHour = await _context.BooruPosts.CountAsync(p => p.UploadedAt >= hourAgo); var booruDay = await _context.BooruPosts.CountAsync(p => p.UploadedAt >= dayAgo); var booruWeek = await _context.BooruPosts.CountAsync(p => p.UploadedAt >= weekAgo); var booruMonth = await _context.BooruPosts.CountAsync(p => p.UploadedAt >= monthAgo); var forumHour = await _context.ForumPosts.CountAsync(p => p.CreatedAt >= hourAgo); var forumDay = await _context.ForumPosts.CountAsync(p => p.CreatedAt >= dayAgo); var forumWeek = await _context.ForumPosts.CountAsync(p => p.CreatedAt >= weekAgo); var forumMonth = await _context.ForumPosts.CountAsync(p => p.CreatedAt >= monthAgo); var commentHour = await _context.BooruComments.CountAsync(c => c.CreatedAt >= hourAgo); var commentDay = await _context.BooruComments.CountAsync(c => c.CreatedAt >= dayAgo); var commentWeek = await _context.BooruComments.CountAsync(c => c.CreatedAt >= weekAgo); var commentMonth = await _context.BooruComments.CountAsync(c => c.CreatedAt >= monthAgo); var booruEvents = await _context.BooruPosts .Where(p => p.UploadedAt >= rangeStartUtc && p.UploadedAt < rangeEndExclusiveUtc) .Select(p => new ActivityEvent(p.UploadedAt, p.UploaderId)) .ToListAsync(); var forumEvents = await _context.ForumPosts .Where(p => p.CreatedAt >= rangeStartUtc && p.CreatedAt < rangeEndExclusiveUtc) .Select(p => new ActivityEvent(p.CreatedAt, p.AuthorId)) .ToListAsync(); var commentEvents = await _context.BooruComments .Where(c => c.CreatedAt >= rangeStartUtc && c.CreatedAt < rangeEndExclusiveUtc) .Select(c => new ActivityEvent(c.CreatedAt, c.UserId)) .ToListAsync(); var booruCounts = BuildDailyCountMap(booruEvents); var forumCounts = BuildDailyCountMap(forumEvents); var commentCounts = BuildDailyCountMap(commentEvents); var uniquePostingUsers = BuildDistinctUserCountMap(booruEvents.Concat(forumEvents)); var uniqueActiveUsers = BuildDistinctUserCountMap(booruEvents.Concat(forumEvents).Concat(commentEvents)); var daily = new List(); for (var day = dateFrom; day <= dateTo; day = day.AddDays(1)) { var booruPosts = booruCounts.GetValueOrDefault(day); var forumPosts = forumCounts.GetValueOrDefault(day); var comments = commentCounts.GetValueOrDefault(day); daily.Add(new ActivityDailyPointDto { Date = day, BooruPosts = booruPosts, ForumPosts = forumPosts, Comments = comments, PostsPerDay = booruPosts + forumPosts, TotalActivity = booruPosts + forumPosts + comments, UniquePostingUsers = uniquePostingUsers.GetValueOrDefault(day), UniqueActiveUsers = uniqueActiveUsers.GetValueOrDefault(day) }); } return new ActivityStatsDto { BooruPosts = new ActivityPeriodCounts { Hour = booruHour, Day = booruDay, Week = booruWeek, Month = booruMonth }, ForumPosts = new ActivityPeriodCounts { Hour = forumHour, Day = forumDay, Week = forumWeek, Month = forumMonth }, Comments = new ActivityPeriodCounts { Hour = commentHour, Day = commentDay, Week = commentWeek, Month = commentMonth }, Overall = new ActivityPeriodCounts { Hour = booruHour + forumHour + commentHour, Day = booruDay + forumDay + commentDay, Week = booruWeek + forumWeek + commentWeek, Month = booruMonth + forumMonth + commentMonth }, DateFrom = dateFrom, DateTo = dateTo, Daily = daily }; } private static (DateOnly DateFrom, DateOnly DateTo) ResolveActivityRange(ActivityStatsQueryDto query, DateOnly today) { var dateTo = query.DateTo ?? today; if (dateTo > today) dateTo = today; if (query.DateFrom.HasValue) { var dateFrom = query.DateFrom.Value; if (dateFrom > dateTo) dateFrom = dateTo; var earliestAllowedDate = dateTo.AddDays(-(MaxActivityRangeDays - 1)); if (dateFrom < earliestAllowedDate) dateFrom = earliestAllowedDate; return (dateFrom, dateTo); } var days = Math.Clamp(query.Days ?? DefaultActivityRangeDays, 1, MaxActivityRangeDays); var dateFromForDays = dateTo.AddDays(-(days - 1)); return (dateFromForDays, dateTo); } private static DateTime AsUtcStart(DateOnly date) { return DateTime.SpecifyKind(date.ToDateTime(TimeOnly.MinValue), DateTimeKind.Utc); } private static Dictionary BuildDailyCountMap(IEnumerable events) { return events .GroupBy(e => DateOnly.FromDateTime(e.Timestamp)) .ToDictionary(g => g.Key, g => g.Count()); } private static Dictionary BuildDistinctUserCountMap(IEnumerable events) { return events .GroupBy(e => DateOnly.FromDateTime(e.Timestamp)) .ToDictionary(g => g.Key, g => g.Select(e => e.UserId).Distinct().Count()); } private sealed record ActivityEvent(DateTime Timestamp, Guid UserId); private async Task MapUserToAdminDtoAsync(ApplicationUser user) { var roles = await _userManager.GetRolesAsync(user); var activeBans = await _context.Bans .Include(b => b.BannedBy) .Where(b => b.User.Id == user.Id) .Where(b => b.Active && b.StartTime <= DateTime.UtcNow && b.EndTime > DateTime.UtcNow) .Select(b => new BanDto { Id = b.Id, Reason = b.Reason, StartTime = b.StartTime, EndTime = b.EndTime, Zone = b.Zone, Active = b.Active, IsBanActive = b.Active && b.StartTime <= DateTime.UtcNow && b.EndTime > DateTime.UtcNow, User = new UserDto { Id = user.Id, UserName = user.UserName ?? string.Empty }, BannedBy = b.BannedBy != null ? new UserDto { Id = b.BannedBy.Id, UserName = b.BannedBy.UserName ?? string.Empty } : null }) .ToListAsync(); return new UserAdminDto { Id = user.Id, UserName = user.UserName ?? string.Empty, DateCreated = user.DateCreated, Roles = roles, ActiveBans = activeBans, IsBanned = activeBans.Any(), Status = user.Status, Biography = user.Biography, IsBabyMode = user.IsBabyMode }; } } }