using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.Models; namespace Nuuru.Server.Services { public interface INotificationService { Task<(IEnumerable Notifications, int TotalCount)> GetUserNotificationsPagedAsync(Guid userId, int page = 1, int pageSize = 20); Task GetUnreadCountAsync(Guid userId); Task MarkAsReadAsync(Guid notificationId, Guid userId); Task MarkAllAsReadAsync(Guid userId); Task DeleteNotificationAsync(Guid notificationId, Guid userId); Task CreateCommentNotificationAsync(int postId, int commentId, Guid commentAuthorId, Guid postOwnerId); Task CreateMentionNotificationsAsync(IEnumerable mentionedUserIds, int commentId, int postId, Guid authorId); Task CreateForumMentionNotificationsAsync(IEnumerable mentionedUserIds, int forumPostId, Guid authorId); Task CreateSystemAnnouncementAsync(string message); Task CreatePostApprovedNotificationAsync(int postId, Guid postOwnerId, Guid moderatorId); Task CreatePostRejectedNotificationAsync(int postId, Guid postOwnerId, Guid moderatorId, string reason); Task CreateReportResolvedNotificationAsync(Guid reporterId, Guid moderatorId, string targetType, string targetId, string? note); Task CreateReportDismissedNotificationAsync(Guid reporterId, Guid moderatorId, string targetType, string targetId, string? note); Task CreatePrivateMessageNotificationAsync(Guid conversationId, int messageId, Guid senderId, IEnumerable recipientIds); Task CreateWatchedPostCommentNotificationsAsync(int postId, int commentId, Guid commentAuthorId); Task CreateWatchedThreadReplyNotificationsAsync(int threadId, int forumPostId, Guid postAuthorId, IEnumerable? excludeUserIds = null); Task> CreateForumQuoteNotificationsAsync(IEnumerable quotedForumPostIds, int forumPostId, Guid authorId); Task CreatePostTrashedNotificationAsync(int postId, Guid postOwnerId, Guid trashedById, string reason); Task CreatePostRestoredNotificationAsync(int postId, Guid postOwnerId, Guid restoredById); Task MarkReadByPostAsync(Guid userId, int postId); Task MarkReadByForumThreadAsync(Guid userId, int forumThreadId); Task MarkReadByConversationAsync(Guid userId, Guid conversationId); Task CreateReactionNotificationAsync(ReactionEntityType entityType, int entityId, Guid reactorId, Guid contentOwnerId, string emoteName); Task CreateFriendAddedNotificationAsync(Guid recipientUserId, Guid initiatorUserId); Task CreateFriendRemovedNotificationAsync(Guid recipientUserId, Guid initiatorUserId); Task CreateEnemyAddedNotificationAsync(Guid recipientUserId, Guid initiatorUserId); } public class NotificationService : INotificationService { private readonly ApplicationDbContext _context; private readonly IWatchService _watchService; private readonly IUserSettingsService _settingsService; private readonly ILogger _logger; public NotificationService(ApplicationDbContext context, IWatchService watchService, IUserSettingsService settingsService, ILogger logger) { _context = context; _watchService = watchService; _settingsService = settingsService; _logger = logger; } public async Task<(IEnumerable Notifications, int TotalCount)> GetUserNotificationsPagedAsync(Guid userId, int page = 1, int pageSize = 20) { var query = _context.Notifications .Include(n => n.TriggeredByUser) .Where(n => n.UserId == userId); var totalCount = await query.CountAsync(); var notifications = await query .OrderByDescending(n => n.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return (notifications, totalCount); } public async Task GetUnreadCountAsync(Guid userId) { return await _context.Notifications .CountAsync(n => n.UserId == userId && !n.IsRead); } public async Task MarkAsReadAsync(Guid notificationId, Guid userId) { var notification = await _context.Notifications .FirstOrDefaultAsync(n => n.Id == notificationId && n.UserId == userId); if (notification != null) { notification.IsRead = true; await _context.SaveChangesAsync(); } } public async Task MarkAllAsReadAsync(Guid userId) { await _context.Notifications .Where(n => n.UserId == userId && !n.IsRead) .ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true)); } public async Task DeleteNotificationAsync(Guid notificationId, Guid userId) { await _context.Notifications .Where(n => n.Id == notificationId && n.UserId == userId) .ExecuteDeleteAsync(); } public async Task CreateCommentNotificationAsync(int postId, int commentId, Guid commentAuthorId, Guid postOwnerId) { // Don't notify if user comments on their own post if (commentAuthorId == postOwnerId) return; // Check if post owner has this notification enabled if (!await _settingsService.IsNotificationEnabledAsync(postOwnerId, NotificationType.CommentOnPost)) return; var commenter = await _context.Users.FindAsync(commentAuthorId); if (commenter == null) return; var notification = new Notification { Type = NotificationType.CommentOnPost, Message = $"{commenter.UserName} commented on your post", UserId = postOwnerId, TriggeredByUserId = commentAuthorId, RelatedPostId = postId, RelatedCommentId = commentId }; _context.Notifications.Add(notification); await _context.SaveChangesAsync(); _logger.LogInformation("Created comment notification for user {UserId} on post {PostId}", postOwnerId, postId); } public async Task CreateMentionNotificationsAsync(IEnumerable mentionedUserIds, int commentId, int postId, Guid authorId) { // Filter out the author from mentions (don't notify yourself) var filteredIds = mentionedUserIds.Where(id => id != authorId).Distinct().ToList(); if (filteredIds.Count == 0) return; var author = await _context.Users.FindAsync(authorId); if (author == null) return; // Verify mentioned users exist var existingUserIds = await _context.Users .Where(u => filteredIds.Contains(u.Id)) .Select(u => u.Id) .ToListAsync(); // Batch-check preferences var enabledMap = await _settingsService.AreNotificationsEnabledAsync(existingUserIds, NotificationType.Mention); // Filter out users who have BlockMentionsFromEnemies enabled and have the author as enemy var blockedByEnemy = await GetEnemyMentionBlockedUserIds(existingUserIds, authorId); var mentionNotificationCount = 0; foreach (var userId in existingUserIds) { if (enabledMap.TryGetValue(userId, out var enabled) && !enabled) continue; if (blockedByEnemy.Contains(userId)) continue; var notification = new Notification { Type = NotificationType.Mention, Message = $"{author.UserName} mentioned you in a comment", UserId = userId, TriggeredByUserId = authorId, RelatedPostId = postId, RelatedCommentId = commentId }; _context.Notifications.Add(notification); mentionNotificationCount++; } if (mentionNotificationCount > 0) { await _context.SaveChangesAsync(); _logger.LogInformation("Created {Count} mention notifications for comment {CommentId}", mentionNotificationCount, commentId); } } public async Task CreateForumMentionNotificationsAsync(IEnumerable mentionedUserIds, int forumPostId, Guid authorId) { // Filter out the author from mentions (don't notify yourself) var filteredIds = mentionedUserIds.Where(id => id != authorId).Distinct().ToList(); if (filteredIds.Count == 0) return; var author = await _context.Users.FindAsync(authorId); if (author == null) return; var forumPost = await _context.ForumPosts .Where(p => p.Id == forumPostId) .Select(p => new { p.ThreadId, CategorySlug = p.Thread.Category.Slug }) .FirstOrDefaultAsync(); if (forumPost == null) return; // Verify mentioned users exist var existingUserIds = await _context.Users .Where(u => filteredIds.Contains(u.Id)) .Select(u => u.Id) .ToListAsync(); // Batch-check preferences var enabledMap = await _settingsService.AreNotificationsEnabledAsync(existingUserIds, NotificationType.Mention); // Filter out users who have BlockMentionsFromEnemies enabled and have the author as enemy var blockedByEnemy = await GetEnemyMentionBlockedUserIds(existingUserIds, authorId); var forumMentionCount = 0; foreach (var userId in existingUserIds) { if (enabledMap.TryGetValue(userId, out var enabled) && !enabled) continue; if (blockedByEnemy.Contains(userId)) continue; var notification = new Notification { Type = NotificationType.Mention, Message = $"{author.UserName} mentioned you in a forum post", UserId = userId, TriggeredByUserId = authorId, RelatedForumPostId = forumPostId, RelatedForumThreadId = forumPost.ThreadId, RelatedForumCategorySlug = forumPost.CategorySlug }; _context.Notifications.Add(notification); forumMentionCount++; } if (forumMentionCount > 0) { await _context.SaveChangesAsync(); _logger.LogInformation("Created {Count} mention notifications for forum post {ForumPostId}", forumMentionCount, forumPostId); } } public async Task CreateSystemAnnouncementAsync(string message) { var allUserIds = await _context.Users.Select(u => u.Id).ToListAsync(); foreach (var userId in allUserIds) { var notification = new Notification { Type = NotificationType.SystemAnnouncement, Message = message, UserId = userId }; _context.Notifications.Add(notification); } await _context.SaveChangesAsync(); _logger.LogInformation("Created system announcement for {Count} users", allUserIds.Count); } public async Task CreatePostApprovedNotificationAsync(int postId, Guid postOwnerId, Guid moderatorId) { var moderator = await _context.Users.FindAsync(moderatorId); var moderatorName = moderator?.UserName ?? "A moderator"; var notification = new Notification { Type = NotificationType.PostApproved, Message = $"{moderatorName} approved your post #{postId}", UserId = postOwnerId, TriggeredByUserId = moderatorId, RelatedPostId = postId }; _context.Notifications.Add(notification); await _context.SaveChangesAsync(); _logger.LogInformation("Created post approved notification for user {UserId} on post {PostId}", postOwnerId, postId); } public async Task CreatePostRejectedNotificationAsync(int postId, Guid postOwnerId, Guid moderatorId, string reason) { var moderator = await _context.Users.FindAsync(moderatorId); var moderatorName = moderator?.UserName ?? "A moderator"; var notification = new Notification { Type = NotificationType.PostRejected, Message = $"{moderatorName} rejected your post #{postId}: {reason}", UserId = postOwnerId, TriggeredByUserId = moderatorId, RelatedPostId = postId }; _context.Notifications.Add(notification); await _context.SaveChangesAsync(); _logger.LogInformation("Created post rejected notification for user {UserId} on post {PostId}", postOwnerId, postId); } public async Task CreateReportResolvedNotificationAsync(Guid reporterId, Guid moderatorId, string targetType, string targetId, string? note) { var moderator = await _context.Users.FindAsync(moderatorId); var moderatorName = moderator?.UserName ?? "A moderator"; var targetDescription = await GetReportTargetDescriptionAsync(targetType, targetId); var message = $"{moderatorName} resolved your report on {targetDescription}"; if (!string.IsNullOrWhiteSpace(note)) { message += $": {note}"; } var notification = new Notification { Type = NotificationType.ReportResolved, Message = message, UserId = reporterId, TriggeredByUserId = moderatorId }; _context.Notifications.Add(notification); await _context.SaveChangesAsync(); _logger.LogInformation("Created report resolved notification for user {UserId}", reporterId); } public async Task CreateReportDismissedNotificationAsync(Guid reporterId, Guid moderatorId, string targetType, string targetId, string? note) { var moderator = await _context.Users.FindAsync(moderatorId); var moderatorName = moderator?.UserName ?? "A moderator"; var targetDescription = await GetReportTargetDescriptionAsync(targetType, targetId); var message = $"{moderatorName} dismissed your report on {targetDescription}"; if (!string.IsNullOrWhiteSpace(note)) { message += $": {note}"; } var notification = new Notification { Type = NotificationType.ReportDismissed, Message = message, UserId = reporterId, TriggeredByUserId = moderatorId }; _context.Notifications.Add(notification); await _context.SaveChangesAsync(); _logger.LogInformation("Created report dismissed notification for user {UserId}", reporterId); } public async Task CreatePrivateMessageNotificationAsync(Guid conversationId, int messageId, Guid senderId, IEnumerable recipientIds) { var filteredIds = recipientIds.Where(id => id != senderId).Distinct().ToList(); if (filteredIds.Count == 0) return; var sender = await _context.Users.FindAsync(senderId); if (sender == null) return; // Load conversation with participants to determine group vs 1-on-1 var conversation = await _context.Conversations .Include(c => c.Participants) .ThenInclude(p => p.User) .FirstOrDefaultAsync(c => c.Id == conversationId); // Build notification message text and determine type string notificationMessage; var activeParticipants = conversation?.Participants.Where(p => !p.HasLeft).ToList(); var isGroupDm = activeParticipants != null && activeParticipants.Count > 2; var notificationType = isGroupDm ? NotificationType.GroupMessage : NotificationType.PrivateMessage; if (isGroupDm) { // Group DM — use explicit title or generate from other participant names string groupName; if (!string.IsNullOrWhiteSpace(conversation!.Title)) { groupName = conversation.Title; } else { // Generate from participant names (excluding sender), limited to avoid overly long text var otherNames = activeParticipants! .Where(p => p.UserId != senderId) .Select(p => p.User?.UserName ?? "Unknown") .Take(3) .ToList(); groupName = string.Join(", ", otherNames); if (activeParticipants.Count - 1 > 3) { groupName += $" +{activeParticipants.Count - 1 - 3}"; } } notificationMessage = $"{sender.UserName} sent a message in {groupName}"; } else { notificationMessage = $"{sender.UserName} sent you a message"; } // Verify recipients exist var existingUserIds = await _context.Users .Where(u => filteredIds.Contains(u.Id)) .Select(u => u.Id) .ToListAsync(); // Batch-check preferences var enabledMap = await _settingsService.AreNotificationsEnabledAsync(existingUserIds, notificationType); // Check for existing unread notifications for this conversation+sender to deduplicate var existingNotifications = await _context.Notifications .Where(n => (n.Type == NotificationType.PrivateMessage || n.Type == NotificationType.GroupMessage) && n.RelatedConversationId == conversationId && n.TriggeredByUserId == senderId && !n.IsRead) .ToListAsync(); var existingByUser = existingNotifications .GroupBy(n => n.UserId) .ToDictionary(g => g.Key, g => g.First()); var pmNotificationCount = 0; foreach (var userId in existingUserIds) { if (enabledMap.TryGetValue(userId, out var enabled) && !enabled) continue; // Dedup: if user already has an unread notification from this sender in this conversation, update it if (existingByUser.TryGetValue(userId, out var existing)) { existing.Type = notificationType; existing.RelatedMessageId = messageId; existing.Message = notificationMessage; existing.CreatedAt = DateTime.UtcNow; continue; } var notification = new Notification { Type = notificationType, Message = notificationMessage, UserId = userId, TriggeredByUserId = senderId, RelatedConversationId = conversationId, RelatedMessageId = messageId }; _context.Notifications.Add(notification); pmNotificationCount++; } if (pmNotificationCount > 0 || existingByUser.Count > 0) { await _context.SaveChangesAsync(); _logger.LogInformation("Created {Count} (updated {Updated}) private message notifications for message {MessageId}", pmNotificationCount, existingByUser.Count, messageId); } } public async Task CreateWatchedPostCommentNotificationsAsync(int postId, int commentId, Guid commentAuthorId) { var watcherIds = (await _watchService.GetWatcherUserIdsAsync(WatchTargetType.BooruPost, postId)).ToList(); if (watcherIds.Count == 0) return; // Get the post owner to exclude them (they already get CommentOnPost notification) var post = await _context.BooruPosts.FindAsync(postId); var postOwnerId = post?.UploaderId; var commenter = await _context.Users.FindAsync(commentAuthorId); if (commenter == null) return; // Batch-check preferences for all watchers var enabledMap = await _settingsService.AreNotificationsEnabledAsync(watcherIds, NotificationType.WatchedPostComment); // Get existing unread watch notifications for this post to deduplicate var usersWithUnreadNotification = await _context.Notifications .Where(n => n.Type == NotificationType.WatchedPostComment && n.RelatedPostId == postId && !n.IsRead) .Select(n => n.UserId) .ToHashSetAsync(); var notifications = new List(); foreach (var userId in watcherIds) { // Exclude the comment author and post owner if (userId == commentAuthorId) continue; if (userId == postOwnerId) continue; // Check user preference if (enabledMap.TryGetValue(userId, out var enabled) && !enabled) continue; // Dedup: skip if user already has unread notification for this post if (usersWithUnreadNotification.Contains(userId)) continue; notifications.Add(new Notification { Type = NotificationType.WatchedPostComment, Message = $"{commenter.UserName} commented on a post you're watching", UserId = userId, TriggeredByUserId = commentAuthorId, RelatedPostId = postId, RelatedCommentId = commentId }); } if (notifications.Count > 0) { _context.Notifications.AddRange(notifications); await _context.SaveChangesAsync(); _logger.LogInformation("Created {Count} watched post comment notifications for post {PostId}", notifications.Count, postId); } } public async Task CreateWatchedThreadReplyNotificationsAsync(int threadId, int forumPostId, Guid postAuthorId, IEnumerable? excludeUserIds = null) { var watcherIds = (await _watchService.GetWatcherUserIdsAsync(WatchTargetType.ForumThread, threadId)).ToList(); if (watcherIds.Count == 0) return; var author = await _context.Users.FindAsync(postAuthorId); if (author == null) return; // Batch-check preferences for all watchers var enabledMap = await _settingsService.AreNotificationsEnabledAsync(watcherIds, NotificationType.WatchedThreadReply); var excludeSet = excludeUserIds?.ToHashSet() ?? []; var categorySlug = await _context.ForumThreads .Where(t => t.Id == threadId) .Select(t => t.Category.Slug) .FirstOrDefaultAsync(); // Get existing unread watch notifications for this thread to deduplicate var usersWithUnreadNotification = await _context.Notifications .Where(n => n.Type == NotificationType.WatchedThreadReply && n.RelatedForumThreadId == threadId && !n.IsRead) .Select(n => n.UserId) .ToHashSetAsync(); var notifications = new List(); foreach (var userId in watcherIds) { // Exclude the post author if (userId == postAuthorId) continue; // Exclude users who already received a quote notification for this post if (excludeSet.Contains(userId)) continue; // Check user preference if (enabledMap.TryGetValue(userId, out var enabled) && !enabled) continue; // Dedup: skip if user already has unread notification for this thread if (usersWithUnreadNotification.Contains(userId)) continue; notifications.Add(new Notification { Type = NotificationType.WatchedThreadReply, Message = $"{author.UserName} replied to a thread you're watching", UserId = userId, TriggeredByUserId = postAuthorId, RelatedForumPostId = forumPostId, RelatedForumThreadId = threadId, RelatedForumCategorySlug = categorySlug }); } if (notifications.Count > 0) { _context.Notifications.AddRange(notifications); await _context.SaveChangesAsync(); _logger.LogInformation("Created {Count} watched thread reply notifications for thread {ThreadId}", notifications.Count, threadId); } } public async Task> CreateForumQuoteNotificationsAsync(IEnumerable quotedForumPostIds, int forumPostId, Guid authorId) { var notifiedUserIds = new HashSet(); var postIds = quotedForumPostIds.Distinct().ToList(); if (postIds.Count == 0) return notifiedUserIds; var author = await _context.Users.FindAsync(authorId); if (author == null) return notifiedUserIds; // Look up the authors of the quoted forum posts var quotedPosts = await _context.ForumPosts .Where(p => postIds.Contains(p.Id)) .Select(p => new { p.Id, p.AuthorId, p.ThreadId }) .ToListAsync(); if (quotedPosts.Count == 0) return notifiedUserIds; // Get the thread ID and category slug from the new post for dedup check and navigation var newPost = await _context.ForumPosts .Where(p => p.Id == forumPostId) .Select(p => new { p.ThreadId, CategorySlug = p.Thread.Category.Slug }) .FirstOrDefaultAsync(); if (newPost == null) return notifiedUserIds; // Batch-check preferences for all quoted authors var quotedAuthorIds = quotedPosts.Select(p => p.AuthorId).Distinct().ToList(); var enabledMap = await _settingsService.AreNotificationsEnabledAsync(quotedAuthorIds, NotificationType.ForumQuote); // Get existing unread ForumQuote notifications for this thread to deduplicate var usersWithUnreadQuoteNotification = await _context.Notifications .Where(n => n.Type == NotificationType.ForumQuote && n.RelatedForumThreadId == newPost.ThreadId && !n.IsRead) .Select(n => n.UserId) .ToHashSetAsync(); var notifications = new List(); foreach (var quotedPost in quotedPosts) { // Don't notify self-quotes if (quotedPost.AuthorId == authorId) continue; // Don't double-notify same user if (notifiedUserIds.Contains(quotedPost.AuthorId)) continue; // Check user preference if (enabledMap.TryGetValue(quotedPost.AuthorId, out var enabled) && !enabled) continue; // Dedup: skip if user already has unread quote notification for this thread if (usersWithUnreadQuoteNotification.Contains(quotedPost.AuthorId)) continue; notifications.Add(new Notification { Type = NotificationType.ForumQuote, Message = $"{author.UserName} quoted your forum post", UserId = quotedPost.AuthorId, TriggeredByUserId = authorId, RelatedForumPostId = forumPostId, RelatedForumThreadId = newPost.ThreadId, RelatedForumCategorySlug = newPost.CategorySlug }); notifiedUserIds.Add(quotedPost.AuthorId); } if (notifications.Count > 0) { _context.Notifications.AddRange(notifications); await _context.SaveChangesAsync(); _logger.LogInformation("Created {Count} forum quote notifications for post {ForumPostId}", notifications.Count, forumPostId); } return notifiedUserIds; } public async Task CreatePostTrashedNotificationAsync(int postId, Guid postOwnerId, Guid trashedById, string reason) { // Don't notify if user trashes their own post if (trashedById == postOwnerId) return; var moderator = await _context.Users.FindAsync(trashedById); var moderatorName = moderator?.UserName ?? "A moderator"; var notification = new Notification { Type = NotificationType.PostTrashed, Message = $"{moderatorName} moved your post #{postId} to trash: {reason}", UserId = postOwnerId, TriggeredByUserId = trashedById, RelatedPostId = postId }; _context.Notifications.Add(notification); await _context.SaveChangesAsync(); _logger.LogInformation("Created post trashed notification for user {UserId} on post {PostId}", postOwnerId, postId); } public async Task CreatePostRestoredNotificationAsync(int postId, Guid postOwnerId, Guid restoredById) { // Don't notify if user restores their own post if (restoredById == postOwnerId) return; var moderator = await _context.Users.FindAsync(restoredById); var moderatorName = moderator?.UserName ?? "A moderator"; var notification = new Notification { Type = NotificationType.PostRestored, Message = $"{moderatorName} restored your post #{postId} from trash", UserId = postOwnerId, TriggeredByUserId = restoredById, RelatedPostId = postId }; _context.Notifications.Add(notification); await _context.SaveChangesAsync(); _logger.LogInformation("Created post restored notification for user {UserId} on post {PostId}", postOwnerId, postId); } public async Task MarkReadByPostAsync(Guid userId, int postId) { await _context.Notifications .Where(n => n.UserId == userId && !n.IsRead && n.RelatedPostId == postId) .ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true)); } public async Task MarkReadByForumThreadAsync(Guid userId, int forumThreadId) { await _context.Notifications .Where(n => n.UserId == userId && !n.IsRead && n.RelatedForumThreadId == forumThreadId) .ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true)); } public async Task MarkReadByConversationAsync(Guid userId, Guid conversationId) { await _context.Notifications .Where(n => n.UserId == userId && !n.IsRead && n.RelatedConversationId == conversationId) .ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true)); } public async Task CreateReactionNotificationAsync(ReactionEntityType entityType, int entityId, Guid reactorId, Guid contentOwnerId, string emoteName) { if (reactorId == contentOwnerId) return; if (!await _settingsService.IsNotificationEnabledAsync(contentOwnerId, NotificationType.Reaction)) return; // Dedup: check for existing unread Reaction notification for the same entity var hasExisting = entityType switch { ReactionEntityType.BooruPost => await _context.Notifications.AnyAsync(n => n.Type == NotificationType.Reaction && n.UserId == contentOwnerId && n.RelatedPostId == entityId && !n.IsRead), ReactionEntityType.BooruComment => await _context.Notifications.AnyAsync(n => n.Type == NotificationType.Reaction && n.UserId == contentOwnerId && n.RelatedCommentId == entityId && !n.IsRead), ReactionEntityType.ForumPost => await _context.Notifications.AnyAsync(n => n.Type == NotificationType.Reaction && n.UserId == contentOwnerId && n.RelatedForumPostId == entityId && !n.IsRead), _ => false, }; if (hasExisting) return; var contentType = entityType switch { ReactionEntityType.BooruPost => "post", ReactionEntityType.BooruComment => "comment", ReactionEntityType.ForumPost => "forum post", _ => "content", }; var notification = new Notification { Type = NotificationType.Reaction, Message = $"Someone reacted to your {contentType}", UserId = contentOwnerId, ReactionEmoteName = emoteName, }; switch (entityType) { case ReactionEntityType.BooruPost: notification.RelatedPostId = entityId; break; case ReactionEntityType.BooruComment: notification.RelatedCommentId = entityId; // Also set the post ID for navigation var postId = await _context.BooruComments.Where(c => c.Id == entityId).Select(c => c.PostId).FirstOrDefaultAsync(); if (postId != 0) notification.RelatedPostId = postId; break; case ReactionEntityType.ForumPost: notification.RelatedForumPostId = entityId; var forumPost = await _context.ForumPosts .Where(p => p.Id == entityId) .Select(p => new { p.ThreadId, CategorySlug = p.Thread.Category.Slug }) .FirstOrDefaultAsync(); if (forumPost != null) { notification.RelatedForumThreadId = forumPost.ThreadId; notification.RelatedForumCategorySlug = forumPost.CategorySlug; } break; } _context.Notifications.Add(notification); await _context.SaveChangesAsync(); _logger.LogInformation("Created reaction notification for user {UserId} on {EntityType} {EntityId}", contentOwnerId, entityType, entityId); } public async Task CreateFriendAddedNotificationAsync(Guid recipientUserId, Guid initiatorUserId) { if (recipientUserId == initiatorUserId) return; if (!await _settingsService.IsNotificationEnabledAsync(recipientUserId, NotificationType.FriendAdded)) return; // Dedup: check for existing unread FriendAdded from same user var hasExisting = await _context.Notifications.AnyAsync(n => n.Type == NotificationType.FriendAdded && n.UserId == recipientUserId && n.TriggeredByUserId == initiatorUserId && !n.IsRead); if (hasExisting) return; var initiator = await _context.Users.FindAsync(initiatorUserId); if (initiator == null) return; _context.Notifications.Add(new Notification { Type = NotificationType.FriendAdded, Message = $"{initiator.UserName} added you as a friend", UserId = recipientUserId, TriggeredByUserId = initiatorUserId }); await _context.SaveChangesAsync(); _logger.LogInformation("Created FriendAdded notification for user {UserId} from {InitiatorId}", recipientUserId, initiatorUserId); } public async Task CreateFriendRemovedNotificationAsync(Guid recipientUserId, Guid initiatorUserId) { if (recipientUserId == initiatorUserId) return; if (!await _settingsService.IsNotificationEnabledAsync(recipientUserId, NotificationType.FriendRemoved)) return; var initiator = await _context.Users.FindAsync(initiatorUserId); if (initiator == null) return; _context.Notifications.Add(new Notification { Type = NotificationType.FriendRemoved, Message = $"{initiator.UserName} removed you as a friend", UserId = recipientUserId, TriggeredByUserId = initiatorUserId }); await _context.SaveChangesAsync(); _logger.LogInformation("Created FriendRemoved notification for user {UserId} from {InitiatorId}", recipientUserId, initiatorUserId); } public async Task CreateEnemyAddedNotificationAsync(Guid recipientUserId, Guid initiatorUserId) { if (recipientUserId == initiatorUserId) return; if (!await _settingsService.IsNotificationEnabledAsync(recipientUserId, NotificationType.EnemyAdded)) return; // Dedup: check for existing unread EnemyAdded from same user var hasExisting = await _context.Notifications.AnyAsync(n => n.Type == NotificationType.EnemyAdded && n.UserId == recipientUserId && n.TriggeredByUserId == initiatorUserId && !n.IsRead); if (hasExisting) return; var initiator = await _context.Users.FindAsync(initiatorUserId); if (initiator == null) return; _context.Notifications.Add(new Notification { Type = NotificationType.EnemyAdded, Message = $"{initiator.UserName} added you as an enemy", UserId = recipientUserId, TriggeredByUserId = initiatorUserId }); await _context.SaveChangesAsync(); _logger.LogInformation("Created EnemyAdded notification for user {UserId} from {InitiatorId}", recipientUserId, initiatorUserId); } private async Task GetReportTargetDescriptionAsync(string targetType, string targetId) { switch (targetType) { case "Post": return $"post #{targetId}"; case "Comment": return $"comment #{targetId}"; case "ForumPost": return $"forum post #{targetId}"; case "User": if (Guid.TryParse(targetId, out _)) { var user = await _context.Users.FindAsync(Guid.Parse(targetId)); if (user != null) return $"user @{user.UserName}"; } else { return $"user @{targetId}"; } return "a user"; default: return $"a {targetType.ToLower()}"; } } /// /// Returns user IDs from recipientIds who have BlockMentionsFromEnemies enabled /// and have the sender as their enemy. /// private async Task> GetEnemyMentionBlockedUserIds(List recipientIds, Guid senderId) { if (recipientIds.Count == 0) return []; var blockingUserIds = await _context.UserSettings .Where(s => recipientIds.Contains(s.UserId) && s.BlockMentionsFromEnemies) .Select(s => s.UserId) .ToListAsync(); if (blockingUserIds.Count == 0) return []; return await _context.UserRelations .Where(r => blockingUserIds.Contains(r.UserId) && r.TargetUserId == senderId && r.Type == Models.UserRelationType.Enemy) .Select(r => r.UserId) .ToHashSetAsync(); } } }