using Microsoft.EntityFrameworkCore; using Nuuru.Server.Constants; using Nuuru.Server.Data; using Nuuru.Server.DTOs.Reaction; using Nuuru.Server.Models; namespace Nuuru.Server.Services { public interface IReactionService { Task GetReactionsAsync(ReactionEntityType entityType, int entityId, Guid? userId = null); Task> GetBatchReactionsAsync(ReactionEntityType entityType, IEnumerable entityIds, Guid? userId = null); Task ToggleReactionAsync(ReactionEntityType entityType, int entityId, Guid userId, string emoteName); bool IsValidEmote(string emoteName); IReadOnlyList GetValidEmotes(); } public class ReactionService : IReactionService { private readonly ApplicationDbContext _context; private readonly INotificationService _notificationService; private readonly IUserSettingsService _settingsService; private readonly ILogger _logger; private const int MaxReactionsPerUserPerEntity = 3; private readonly IBointsService _bointsService; public ReactionService(ApplicationDbContext context, INotificationService notificationService, IUserSettingsService settingsService, IBointsService bointsService, ILogger logger) { _context = context; _notificationService = notificationService; _settingsService = settingsService; _bointsService = bointsService; _logger = logger; } public bool IsValidEmote(string emoteName) { return Emotes.IsBuiltIn(emoteName); } public IReadOnlyList GetValidEmotes() { return Emotes.GetAll(); } public async Task GetReactionsAsync(ReactionEntityType entityType, int entityId, Guid? userId = null) { var reactions = await _context.Reactions .Where(r => r.EntityType == entityType && r.EntityId == entityId) .GroupBy(r => r.EmoteName) .Select(g => new { EmoteName = g.Key, Count = g.Count(), FirstCreatedAt = g.Min(r => r.CreatedAt) }) .OrderBy(r => r.FirstCreatedAt) .Select(r => new EmoteCount { EmoteName = r.EmoteName, Count = r.Count }) .ToListAsync(); List? userReactions = null; if (userId.HasValue) { userReactions = await _context.Reactions .Where(r => r.EntityType == entityType && r.EntityId == entityId && r.UserId == userId.Value) .Select(r => r.EmoteName) .ToListAsync(); } return new ReactionDTO { Reactions = reactions, UserReactions = userReactions, TotalCount = reactions.Sum(r => r.Count) }; } public async Task> GetBatchReactionsAsync(ReactionEntityType entityType, IEnumerable entityIds, Guid? userId = null) { var entityIdList = entityIds.ToList(); if (entityIdList.Count == 0) { return new Dictionary(); } // Get all reactions for these entities in one query var allReactions = await _context.Reactions .Where(r => r.EntityType == entityType && entityIdList.Contains(r.EntityId)) .GroupBy(r => new { r.EntityId, r.EmoteName }) .Select(g => new { g.Key.EntityId, g.Key.EmoteName, Count = g.Count(), FirstCreatedAt = g.Min(r => r.CreatedAt) }) .ToListAsync(); // Get user's reactions if authenticated HashSet<(int EntityId, string EmoteName)>? userReactionSet = null; if (userId.HasValue) { var userReactions = await _context.Reactions .Where(r => r.EntityType == entityType && entityIdList.Contains(r.EntityId) && r.UserId == userId.Value) .Select(r => new { r.EntityId, r.EmoteName }) .ToListAsync(); userReactionSet = userReactions.Select(r => (r.EntityId, r.EmoteName)).ToHashSet(); } // Build response dictionary var result = new Dictionary(); foreach (var entityId in entityIdList) { var entityReactions = allReactions .Where(r => r.EntityId == entityId) .OrderBy(r => r.FirstCreatedAt) .Select(r => new EmoteCount { EmoteName = r.EmoteName, Count = r.Count }) .ToList(); List? userEntityReactions = null; if (userReactionSet != null) { userEntityReactions = userReactionSet .Where(r => r.EntityId == entityId) .Select(r => r.EmoteName) .ToList(); } result[entityId] = new ReactionDTO { Reactions = entityReactions, UserReactions = userEntityReactions, TotalCount = entityReactions.Sum(r => r.Count) }; } return result; } public async Task ToggleReactionAsync(ReactionEntityType entityType, int entityId, Guid userId, string emoteName) { // Normalize emote name to lowercase emoteName = emoteName.ToLowerInvariant(); if (!IsValidEmote(emoteName)) { _logger.LogWarning("Invalid emote name {EmoteName} from user {UserId}", emoteName, userId); return null; } var existingReaction = await _context.Reactions .FirstOrDefaultAsync(r => r.EntityType == entityType && r.EntityId == entityId && r.UserId == userId && r.EmoteName == emoteName); if (existingReaction != null) { // Remove the reaction (toggle off) _context.Reactions.Remove(existingReaction); _logger.LogInformation("User {UserId} removed {EmoteName} reaction from {EntityType} {EntityId}", userId, emoteName, entityType, entityId); } else { // Check if user has reached the reaction limit for this entity var currentCount = await _context.Reactions .CountAsync(r => r.EntityType == entityType && r.EntityId == entityId && r.UserId == userId); if (currentCount >= MaxReactionsPerUserPerEntity) { _logger.LogInformation("User {UserId} has reached max reactions ({Max}) on {EntityType} {EntityId}", userId, MaxReactionsPerUserPerEntity, entityType, entityId); // Return current state without adding return await GetReactionsAsync(entityType, entityId, userId); } // Add the reaction (toggle on) var reaction = new Reaction { EntityType = entityType, EntityId = entityId, UserId = userId, EmoteName = emoteName, CreatedAt = DateTime.UtcNow }; _context.Reactions.Add(reaction); _logger.LogInformation("User {UserId} added {EmoteName} reaction to {EntityType} {EntityId}", userId, emoteName, entityType, entityId); } var isAdding = existingReaction == null; // Determine score delta before saving var contentOwnerId = await GetContentOwnerIdAsync(entityType, entityId); var delta = 0; if (contentOwnerId.HasValue && contentOwnerId.Value != userId) { var emoteValue = Emotes.GetValue(emoteName); delta = isAdding ? emoteValue : -emoteValue; } // Save reaction + score update in a transaction using var transaction = await _context.Database.BeginTransactionAsync(); try { await _context.SaveChangesAsync(); if (delta != 0) { await _context.Users .Where(u => u.Id == contentOwnerId!.Value) .ExecuteUpdateAsync(s => s.SetProperty( u => u.ReactionScore, u => u.ReactionScore + delta)); } await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; } // Notification is outside the transaction — it's fine if it fails independently if (isAdding && contentOwnerId.HasValue && contentOwnerId.Value != userId) { await _notificationService.CreateReactionNotificationAsync(entityType, entityId, userId, contentOwnerId.Value, emoteName); // Credit content owner with boints for receiving a reaction var sourcePostId = entityType == ReactionEntityType.BooruPost ? entityId : (int?)null; var sourceCommentId = entityType == ReactionEntityType.BooruComment ? entityId : (int?)null; var sourceForumPostId = entityType == ReactionEntityType.ForumPost ? entityId : (int?)null; await _bointsService.CreditAsync(contentOwnerId.Value, BointsReason.ReactionReceived, 1, sourcePostId: sourcePostId, sourceCommentId: sourceCommentId, sourceForumPostId: sourceForumPostId, sourceUserId: userId); } return await GetReactionsAsync(entityType, entityId, userId); } private async Task GetContentOwnerIdAsync(ReactionEntityType entityType, int entityId) { return entityType switch { ReactionEntityType.BooruPost => await _context.BooruPosts .Where(p => p.Id == entityId).Select(p => (Guid?)p.UploaderId).FirstOrDefaultAsync(), ReactionEntityType.BooruComment => await _context.BooruComments .Where(c => c.Id == entityId).Select(c => (Guid?)c.UserId).FirstOrDefaultAsync(), ReactionEntityType.ForumPost => await _context.ForumPosts .Where(p => p.Id == entityId).Select(p => (Guid?)p.AuthorId).FirstOrDefaultAsync(), _ => null, }; } } }