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;
}
}
}