using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Nuuru.Server.Auth; using Nuuru.Server.Data; using Nuuru.Server.Models; namespace Nuuru.Server.Services { /// /// User display information including badges and role color /// public class UserDisplayInfo { public Guid UserId { get; set; } public string? RoleColor { get; set; } public string? RoleName { get; set; } public IReadOnlyList Badges { get; set; } = []; public IReadOnlyList ActiveBanZones { get; set; } = []; public IReadOnlyList ActiveBans { get; set; } = []; public int? ClanId { get; set; } public string? ClanTag { get; set; } public string? ClanColor { get; set; } public string? ClanBadgeUrl { get; set; } public bool BointsEnabled { get; set; } = true; } public interface IUserBadgeService { /// /// Gets badge IDs for a user based on their permissions and other criteria /// Task> GetBadgesForUserAsync(Guid userId); /// /// Gets display info (badges + role color) for a single user /// Task GetUserDisplayInfoAsync(Guid userId); /// /// Gets display info for multiple users (batch operation to prevent N+1) /// Task> GetUsersDisplayInfoAsync(IEnumerable userIds); } public class UserBadgeService : IUserBadgeService { private static readonly DateTime LegacyBadgeCutoffUtc = new(2026, 2, 24, 0, 0, 0, DateTimeKind.Utc); private readonly UserManager _userManager; private readonly RoleManager _roleManager; private readonly ApplicationDbContext _context; private readonly ISiteSettingsService _siteSettings; public UserBadgeService( UserManager userManager, RoleManager roleManager, ApplicationDbContext context, ISiteSettingsService siteSettings) { _userManager = userManager; _roleManager = roleManager; _context = context; _siteSettings = siteSettings; } public async Task> GetBadgesForUserAsync(Guid userId) { var displayInfo = await GetUserDisplayInfoAsync(userId); return displayInfo.Badges; } /// /// Computes badges for a user based on their effective permissions and active bans /// private IReadOnlyList ComputeBadgesForUser( ApplicationUser user, IEnumerable effectivePermissions, IEnumerable<(BanZone Zone, DateTime EndTime, string Reason, string? BannedByUserName)> activeBans) { var badges = UserBadges.GetBadgeIds( effectivePermissions, activeBans.Select(b => (b.Zone, b.EndTime))).ToList(); if (user.IsBabyMode) badges.Add("babymode"); if (user.DateCreated < LegacyBadgeCutoffUtc) badges.Add("legacy"); return badges; } public async Task GetUserDisplayInfoAsync(Guid userId) { var results = await GetUsersDisplayInfoAsync([userId]); return results.TryGetValue(userId, out var info) ? info : new UserDisplayInfo { UserId = userId }; } public async Task> GetUsersDisplayInfoAsync(IEnumerable userIds) { var userIdList = userIds.Distinct().ToList(); if (userIdList.Count == 0) return new Dictionary(); var result = new Dictionary(); // Get all users var users = await _userManager.Users .Where(u => userIdList.Contains(u.Id)) .ToListAsync(); // Get all user claims in batch var userClaimsQuery = await _context.UserClaims .Where(c => userIdList.Contains(c.UserId)) .ToListAsync(); var userClaimsMap = userClaimsQuery .GroupBy(c => c.UserId) .ToDictionary(g => g.Key, g => g.ToList()); // Get all user roles in batch var userRolesQuery = await _context.UserRoles .Where(ur => userIdList.Contains(ur.UserId)) .ToListAsync(); var userRolesMap = userRolesQuery .GroupBy(ur => ur.UserId) .ToDictionary(g => g.Key, g => g.Select(ur => ur.RoleId).ToList()); // Get all role IDs we need var allRoleIds = userRolesMap.Values.SelectMany(r => r).Distinct().ToList(); // Get roles var roles = await _roleManager.Roles .Where(r => allRoleIds.Contains(r.Id)) .ToListAsync(); var roleMap = roles.ToDictionary(r => r.Id); // Get role claims in batch var roleClaimsQuery = await _context.RoleClaims .Where(c => allRoleIds.Contains(c.RoleId)) .ToListAsync(); var roleClaimsMap = roleClaimsQuery .GroupBy(c => c.RoleId) .ToDictionary(g => g.Key, g => g.ToList()); // Get all active bans for these users var now = DateTime.UtcNow; var activeBansQuery = await _context.Bans .Include(b => b.BannedBy) .Where(b => userIdList.Contains(b.User.Id) && b.Active && b.StartTime < now && b.EndTime > now) .Select(b => new { b.User.Id, b.Zone, b.EndTime, b.Reason, BannedByUserName = b.BannedBy != null ? b.BannedBy.UserName : null }) .ToListAsync(); var userBansMap = activeBansQuery .GroupBy(b => b.Id) .ToDictionary(g => g.Key, g => g.Select(b => (b.Zone, b.EndTime, b.Reason, b.BannedByUserName)).ToList()); // Process each user foreach (var user in users) { var displayInfo = new UserDisplayInfo { UserId = user.Id }; // Get user's role IDs var roleIds = userRolesMap.TryGetValue(user.Id, out var rids) ? rids : []; // Find highest priority role for color/name ApplicationRole? highestRole = null; foreach (var roleId in roleIds) { if (roleMap.TryGetValue(roleId, out var role)) { if (highestRole == null || role.Priority > highestRole.Priority) { highestRole = role; } } } if (highestRole != null) { displayInfo.RoleColor = highestRole.Color; displayInfo.RoleName = highestRole.Name; } // Compute effective permissions var roleClaims = roleIds .SelectMany(rid => roleClaimsMap.TryGetValue(rid, out var claims) ? claims : []) .Select(c => new System.Security.Claims.Claim(c.ClaimType ?? "", c.ClaimValue ?? "")) .ToList(); var userClaims = userClaimsMap.TryGetValue(user.Id, out var uclaims) ? uclaims.Select(c => new System.Security.Claims.Claim(c.ClaimType ?? "", c.ClaimValue ?? "")).ToList() : []; var effectivePermissions = PermissionCalculator.ComputeEffectivePermissionsFromClaims(roleClaims, userClaims); // Get active ban zones var activeBans = userBansMap.TryGetValue(user.Id, out var bans) ? bans : []; displayInfo.ActiveBanZones = activeBans.Select(b => b.Zone).Distinct().ToList(); displayInfo.ActiveBans = activeBans.Select(b => new DTOs.User.BanInfoDto { Reason = b.Reason, EndTime = b.EndTime, Zone = b.Zone, BannedByUserName = b.BannedByUserName }).ToList(); // Get badges for user based on permissions and bans (includes expiry in badge IDs) displayInfo.Badges = ComputeBadgesForUser(user, effectivePermissions, activeBans); result[user.Id] = displayInfo; } var bointsEnabled = await _siteSettings.GetBoolAsync("boints.enabled", false); foreach (var info in result.Values) info.BointsEnabled = bointsEnabled; // Append clan badges for users who are in clans with a badge set (skip if clans disabled) var clansEnabled = bointsEnabled && await _siteSettings.GetBoolAsync("clans.enabled", false); if (!clansEnabled) return result; var clanMemberships = await _context.ClanMembers .Where(m => userIdList.Contains(m.UserId)) .Select(m => new { m.UserId, m.ClanId, m.Clan.Name, m.Clan.Tag, m.Clan.Color, m.Clan.BadgeStorageIdentifier, m.Clan.UseBadgeAsTag }) .ToListAsync(); foreach (var membership in clanMemberships) { if (result.TryGetValue(membership.UserId, out var info)) { info.ClanId = membership.ClanId; info.ClanTag = membership.Tag; info.ClanColor = membership.Color; if (membership.BadgeStorageIdentifier != null) { if (membership.UseBadgeAsTag) { // Badge replaces tag — show badge inline, not in badge list info.ClanBadgeUrl = $"/api/clans/{membership.ClanId}/badge?v={membership.BadgeStorageIdentifier![..8]}"; } else { // Badge in badge list only var badges = info.Badges.ToList(); badges.Add($"clan:{membership.ClanId}:{membership.Name}"); info.Badges = badges; } } } } return result; } } }