using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.DTOs.Relation; using Nuuru.Server.Models; namespace Nuuru.Server.Services { public interface IUserRelationService { Task AddFriendAsync(Guid userId, Guid targetUserId); Task RemoveFriendAsync(Guid userId, Guid targetUserId); Task> GetFriendsAsync(Guid userId); Task> GetFriendsByUsernameAsync(string username); Task AreFriendsAsync(Guid userA, Guid userB); Task GetRelationStatusAsync(Guid userId, Guid targetUserId); Task AddEnemyAsync(Guid userId, Guid targetUserId); Task RemoveEnemyAsync(Guid userId, Guid targetUserId); Task> GetEnemiesAsync(Guid userId); Task> GetEnemiesByUsernameAsync(string username); Task IsEnemyAsync(Guid userId, Guid targetUserId); Task ShouldBlockMentionAsync(Guid mentionedUserId, Guid mentionerUserId); Task ShouldBlockDmAsync(Guid recipientUserId, Guid senderUserId); Task> FilterBlockedMentionRecipientsAsync(IEnumerable recipientIds, Guid senderId); } public class UserRelationService : IUserRelationService { private static readonly TimeSpan CooldownDuration = TimeSpan.FromMinutes(5); private static readonly TimeSpan ActivityWindow = TimeSpan.FromDays(7); private readonly ApplicationDbContext _context; private readonly INotificationService _notificationService; private readonly ISiteSettingsService _siteSettings; private readonly ILogger _logger; public UserRelationService( ApplicationDbContext context, INotificationService notificationService, ISiteSettingsService siteSettings, ILogger logger) { _context = context; _notificationService = notificationService; _siteSettings = siteSettings; _logger = logger; } public async Task AddFriendAsync(Guid userId, Guid targetUserId) { if (userId == targetUserId) return UserRelationResult.Fail("You cannot friend yourself."); // Check cooldown var cooldownCheck = await CheckCooldownAsync(userId, targetUserId); if (cooldownCheck != null) return cooldownCheck; // Check target was active in past 7 days var activityCheck = await CheckActivityAsync(targetUserId); if (activityCheck != null) return activityCheck; // Check if target has enemied the requesting user — cannot friend someone who has you as enemy var targetEnemiedUser = await _context.UserRelations .AnyAsync(r => r.UserId == targetUserId && r.TargetUserId == userId && r.Type == UserRelationType.Enemy); if (targetEnemiedUser) return UserRelationResult.Fail("This user has declared you as an enemy. You cannot friend them."); // Check friend block (user was unfriended and cannot re-initiate) var isBlocked = await _context.FriendBlocks .AnyAsync(b => b.BlockedUserId == userId && b.BlockedByUserId == targetUserId); if (isBlocked) return UserRelationResult.Fail("You cannot re-friend this user. They must friend you first."); // Check no existing friendship var existingFriendship = await _context.UserRelations .AnyAsync(r => r.Type == UserRelationType.Friend && ((r.UserId == userId && r.TargetUserId == targetUserId) || (r.UserId == targetUserId && r.TargetUserId == userId))); if (existingFriendship) return UserRelationResult.Fail("You are already friends with this user."); // Check target exists var target = await _context.Users.FirstOrDefaultAsync(u => u.Id == targetUserId); if (target == null) return UserRelationResult.Fail("User not found."); // Remove any existing enemy relation from user toward target (friending overrides enemy) var existingEnemy = await _context.UserRelations .FirstOrDefaultAsync(r => r.UserId == userId && r.TargetUserId == targetUserId && r.Type == UserRelationType.Enemy); if (existingEnemy != null) _context.UserRelations.Remove(existingEnemy); // Remove any existing friend block where the target had blocked the user (since user is now being re-friended by a valid path) var reverseBlock = await _context.FriendBlocks .FirstOrDefaultAsync(b => b.BlockedUserId == targetUserId && b.BlockedByUserId == userId); if (reverseBlock != null) _context.FriendBlocks.Remove(reverseBlock); _context.UserRelations.Add(new UserRelation { UserId = userId, TargetUserId = targetUserId, Type = UserRelationType.Friend }); await UpsertCooldownAsync(userId, targetUserId); await _context.SaveChangesAsync(); await _notificationService.CreateFriendAddedNotificationAsync(targetUserId, userId); _logger.LogInformation("User {UserId} added {TargetUserId} as friend", userId, targetUserId); return UserRelationResult.Ok(target.UserName, target.Id); } public async Task RemoveFriendAsync(Guid userId, Guid targetUserId) { if (userId == targetUserId) return UserRelationResult.Fail("Invalid operation."); var cooldownCheck = await CheckCooldownAsync(userId, targetUserId); if (cooldownCheck != null) return cooldownCheck; // Find friendship in either direction var friendship = await _context.UserRelations .Include(r => r.User) .Include(r => r.TargetUser) .FirstOrDefaultAsync(r => r.Type == UserRelationType.Friend && ((r.UserId == userId && r.TargetUserId == targetUserId) || (r.UserId == targetUserId && r.TargetUserId == userId))); if (friendship == null) return UserRelationResult.Fail("You are not friends with this user."); var target = friendship.UserId == userId ? friendship.TargetUser : friendship.User; _context.UserRelations.Remove(friendship); // The unfriended party (the other user) loses re-friend rights // Remove any existing reverse block var existingBlock = await _context.FriendBlocks .FirstOrDefaultAsync(b => b.BlockedUserId == userId && b.BlockedByUserId == targetUserId); if (existingBlock != null) _context.FriendBlocks.Remove(existingBlock); // Block the other party from re-friending _context.FriendBlocks.Add(new FriendBlock { BlockedUserId = targetUserId, BlockedByUserId = userId }); await UpsertCooldownAsync(userId, targetUserId); await _context.SaveChangesAsync(); await _notificationService.CreateFriendRemovedNotificationAsync(targetUserId, userId); _logger.LogInformation("User {UserId} unfriended {TargetUserId}", userId, targetUserId); return UserRelationResult.Ok(target.UserName, target.Id); } public async Task> GetFriendsAsync(Guid userId) { var relations = await _context.UserRelations .Where(r => r.Type == UserRelationType.Friend && (r.UserId == userId || r.TargetUserId == userId)) .Include(r => r.User) .Include(r => r.TargetUser) .ToListAsync(); var bointsEnabled = await _siteSettings.GetBoolAsync("boints.enabled", false); var clansEnabled = bointsEnabled && await _siteSettings.GetBoolAsync("clans.enabled", false); var friends = relations.Select(r => { var friend = r.UserId == userId ? r.TargetUser : r.User; return new FriendDto { Id = friend.Id, UserName = friend.UserName!, AvatarUrl = ApplicationUser.GetAvatarUrl(friend.UserName, friend.AvatarStorageIdentifier), RoleColor = null, FriendsSince = r.CreatedAt, ForcedDisplayName = bointsEnabled ? friend.ActiveForcedDisplayName : null, }; }).ToList(); if (!clansEnabled) return friends; var friendIds = friends.Select(f => f.Id).Distinct().ToList(); var clanMap = await _context.ClanMembers .Where(m => friendIds.Contains(m.UserId)) .Select(m => new { m.UserId, m.ClanId, m.Clan.Tag, m.Clan.Color, m.Clan.BadgeStorageIdentifier, m.Clan.UseBadgeAsTag }) .ToDictionaryAsync(m => m.UserId); foreach (var friend in friends) { if (clanMap.TryGetValue(friend.Id, out var clan)) { friend.ClanId = clan.ClanId; friend.ClanTag = clan.Tag; friend.ClanColor = clan.Color; friend.ClanBadgeUrl = clan is { UseBadgeAsTag: true, BadgeStorageIdentifier: not null } ? $"/api/clans/{clan.ClanId}/badge?v={clan.BadgeStorageIdentifier[..8]}" : null; } } return friends; } public async Task> GetFriendsByUsernameAsync(string username) { var user = await _context.Users.FirstOrDefaultAsync(u => u.UserName == username); if (user == null) return []; return await GetFriendsAsync(user.Id); } public async Task AreFriendsAsync(Guid userA, Guid userB) { return await _context.UserRelations.AnyAsync(r => r.Type == UserRelationType.Friend && ((r.UserId == userA && r.TargetUserId == userB) || (r.UserId == userB && r.TargetUserId == userA))); } public async Task GetRelationStatusAsync(Guid userId, Guid targetUserId) { var response = new RelationStatusResponse(); // Check friendship var areFriends = await AreFriendsAsync(userId, targetUserId); if (areFriends) { response.FriendshipStatus = "friends"; } else { var isBlocked = await _context.FriendBlocks .AnyAsync(b => b.BlockedUserId == userId && b.BlockedByUserId == targetUserId); response.FriendshipStatus = isBlocked ? "blocked" : "none"; } // Check if current user has target as enemy response.IsEnemy = await _context.UserRelations .AnyAsync(r => r.UserId == userId && r.TargetUserId == targetUserId && r.Type == UserRelationType.Enemy); // Check if target has current user as enemy response.IsEnemyOfTarget = await _context.UserRelations .AnyAsync(r => r.UserId == targetUserId && r.TargetUserId == userId && r.Type == UserRelationType.Enemy); return response; } public async Task AddEnemyAsync(Guid userId, Guid targetUserId) { if (userId == targetUserId) return UserRelationResult.Fail("You cannot enemy yourself."); var cooldownCheck = await CheckCooldownAsync(userId, targetUserId); if (cooldownCheck != null) return cooldownCheck; var activityCheck = await CheckActivityAsync(targetUserId); if (activityCheck != null) return activityCheck; // Check target exists var target = await _context.Users.FirstOrDefaultAsync(u => u.Id == targetUserId); if (target == null) return UserRelationResult.Fail("User not found."); // Check no existing enemy relation var existing = await _context.UserRelations .AnyAsync(r => r.UserId == userId && r.TargetUserId == targetUserId && r.Type == UserRelationType.Enemy); if (existing) return UserRelationResult.Fail("This user is already your enemy."); _context.UserRelations.Add(new UserRelation { UserId = userId, TargetUserId = targetUserId, Type = UserRelationType.Enemy }); await UpsertCooldownAsync(userId, targetUserId); await _context.SaveChangesAsync(); await _notificationService.CreateEnemyAddedNotificationAsync(targetUserId, userId); _logger.LogInformation("User {UserId} added {TargetUserId} as enemy", userId, targetUserId); return UserRelationResult.Ok(target.UserName, target.Id); } public async Task RemoveEnemyAsync(Guid userId, Guid targetUserId) { if (userId == targetUserId) return UserRelationResult.Fail("Invalid operation."); var cooldownCheck = await CheckCooldownAsync(userId, targetUserId); if (cooldownCheck != null) return cooldownCheck; var relation = await _context.UserRelations .Include(r => r.TargetUser) .FirstOrDefaultAsync(r => r.UserId == userId && r.TargetUserId == targetUserId && r.Type == UserRelationType.Enemy); if (relation == null) return UserRelationResult.Fail("This user is not your enemy."); var target = relation.TargetUser; _context.UserRelations.Remove(relation); await UpsertCooldownAsync(userId, targetUserId); await _context.SaveChangesAsync(); _logger.LogInformation("User {UserId} removed {TargetUserId} as enemy", userId, targetUserId); return UserRelationResult.Ok(target.UserName, target.Id); } public async Task> GetEnemiesAsync(Guid userId) { var bointsEnabled = await _siteSettings.GetBoolAsync("boints.enabled", false); var clansEnabled = bointsEnabled && await _siteSettings.GetBoolAsync("clans.enabled", false); var enemies = await _context.UserRelations .Where(r => r.UserId == userId && r.Type == UserRelationType.Enemy) .Include(r => r.TargetUser) .Select(r => new EnemyDto { Id = r.TargetUser.Id, UserName = r.TargetUser.UserName!, AvatarUrl = ApplicationUser.GetAvatarUrl(r.TargetUser.UserName, r.TargetUser.AvatarStorageIdentifier), RoleColor = null, EnemySince = r.CreatedAt, ForcedDisplayName = bointsEnabled && r.TargetUser.ForcedDisplayName != null && r.TargetUser.ForcedDisplayNameUntil > DateTime.UtcNow ? r.TargetUser.ForcedDisplayName : null }) .ToListAsync(); if (!clansEnabled) return enemies; var enemyIds = enemies.Select(e => e.Id).Distinct().ToList(); var clanMap = await _context.ClanMembers .Where(m => enemyIds.Contains(m.UserId)) .Select(m => new { m.UserId, m.ClanId, m.Clan.Tag, m.Clan.Color, m.Clan.BadgeStorageIdentifier, m.Clan.UseBadgeAsTag }) .ToDictionaryAsync(m => m.UserId); foreach (var enemy in enemies) { if (clanMap.TryGetValue(enemy.Id, out var clan)) { enemy.ClanId = clan.ClanId; enemy.ClanTag = clan.Tag; enemy.ClanColor = clan.Color; enemy.ClanBadgeUrl = clan is { UseBadgeAsTag: true, BadgeStorageIdentifier: not null } ? $"/api/clans/{clan.ClanId}/badge?v={clan.BadgeStorageIdentifier[..8]}" : null; } } return enemies; } public async Task> GetEnemiesByUsernameAsync(string username) { var user = await _context.Users.FirstOrDefaultAsync(u => u.UserName == username); if (user == null) return []; return await GetEnemiesAsync(user.Id); } public async Task IsEnemyAsync(Guid userId, Guid targetUserId) { return await _context.UserRelations .AnyAsync(r => r.UserId == userId && r.TargetUserId == targetUserId && r.Type == UserRelationType.Enemy); } public async Task ShouldBlockMentionAsync(Guid mentionedUserId, Guid mentionerUserId) { var settings = await _context.Set() .FirstOrDefaultAsync(s => s.UserId == mentionedUserId); if (settings == null || !settings.BlockMentionsFromEnemies) return false; // Check if the mentioned user has the mentioner as enemy return await IsEnemyAsync(mentionedUserId, mentionerUserId); } public async Task ShouldBlockDmAsync(Guid recipientUserId, Guid senderUserId) { var settings = await _context.Set() .FirstOrDefaultAsync(s => s.UserId == recipientUserId); if (settings == null || !settings.OnlyAllowDmsFromFriends) return false; return !await AreFriendsAsync(recipientUserId, senderUserId); } public async Task> FilterBlockedMentionRecipientsAsync(IEnumerable recipientIds, Guid senderId) { var recipientList = recipientIds.ToList(); if (recipientList.Count == 0) return []; // Get all recipients who have BlockMentionsFromEnemies enabled var blockingUserIds = await _context.Set() .Where(s => recipientList.Contains(s.UserId) && s.BlockMentionsFromEnemies) .Select(s => s.UserId) .ToListAsync(); if (blockingUserIds.Count == 0) return []; // Check which of those users have the sender as enemy var blockedIds = await _context.UserRelations .Where(r => blockingUserIds.Contains(r.UserId) && r.TargetUserId == senderId && r.Type == UserRelationType.Enemy) .Select(r => r.UserId) .ToHashSetAsync(); return blockedIds; } private async Task CheckCooldownAsync(Guid userId, Guid targetUserId) { var cutoff = DateTime.UtcNow - CooldownDuration; var onCooldown = await _context.RelationCooldowns .AnyAsync(c => c.UserId == userId && c.TargetUserId == targetUserId && c.LastActionAt > cutoff); if (onCooldown) return UserRelationResult.Fail("Please wait before performing another action with this user."); return null; } private async Task CheckActivityAsync(Guid targetUserId) { var cutoff = DateTime.UtcNow - ActivityWindow; var isActive = await _context.AuditLogs .AnyAsync(a => a.UserId == targetUserId && a.Timestamp >= cutoff); if (!isActive) return UserRelationResult.Fail("This user has not been active in the past 7 days."); return null; } private async Task UpsertCooldownAsync(Guid userId, Guid targetUserId) { var existing = await _context.RelationCooldowns .FirstOrDefaultAsync(c => c.UserId == userId && c.TargetUserId == targetUserId); if (existing != null) { existing.LastActionAt = DateTime.UtcNow; } else { _context.RelationCooldowns.Add(new RelationCooldown { UserId = userId, TargetUserId = targetUserId, LastActionAt = DateTime.UtcNow }); } } } }