using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.Models.Messaging; namespace Nuuru.Server.Services { public interface IConversationService { Task<(Conversation? Conversation, string? Error)> CreateConversationAsync(Guid creatorId, IEnumerable participantIds, string? title, string initialMessage); Task GetConversationByIdAsync(Guid conversationId, Guid requestingUserId); Task<(IEnumerable Items, int TotalCount)> GetUserConversationsAsync(Guid userId, int page, int pageSize); Task GetUnreadConversationCountAsync(Guid userId); Task AddParticipantAsync(Guid conversationId, Guid addedByUserId, Guid newParticipantId); Task RemoveParticipantAsync(Guid conversationId, Guid removedByUserId, Guid removedParticipantId); Task LeaveConversationAsync(Guid conversationId, Guid userId); Task IsParticipantAsync(Guid conversationId, Guid userId); Task MarkAsReadAsync(Guid conversationId, Guid userId); Task MarkAllAsReadAsync(Guid userId); Task LockConversationAsync(Guid conversationId, bool locked); Task DeleteConversationAsync(Guid conversationId); } public class ConversationService : IConversationService { private readonly ApplicationDbContext _context; private readonly IMessageService _messageService; private readonly IUserRelationService _relationService; private readonly ILiveKitRoomAdminClient _roomAdminClient; private readonly ILogger _logger; public ConversationService( ApplicationDbContext context, IMessageService messageService, IUserRelationService relationService, ILiveKitRoomAdminClient roomAdminClient, ILogger logger) { _context = context; _messageService = messageService; _relationService = relationService; _roomAdminClient = roomAdminClient; _logger = logger; } public async Task<(Conversation? Conversation, string? Error)> CreateConversationAsync(Guid creatorId, IEnumerable participantIds, string? title, string initialMessage) { // Validate creator exists var creator = await _context.Users.FindAsync(creatorId); if (creator == null) { _logger.LogWarning("Creator {CreatorId} not found when creating conversation", creatorId); return (null, "Creator not found."); } // Include creator in participants and remove duplicates var allParticipantIds = participantIds.Append(creatorId).Distinct().ToList(); // Validate all participants exist var existingUserIds = await _context.Users .Where(u => allParticipantIds.Contains(u.Id)) .Select(u => u.Id) .ToListAsync(); if (existingUserIds.Count != allParticipantIds.Count) { _logger.LogWarning("Some participants do not exist when creating conversation"); return (null, "Some participants do not exist."); } // Must have at least 2 participants (including creator) if (allParticipantIds.Count < 2) { _logger.LogWarning("Conversation must have at least 2 participants"); return (null, "Conversation must have at least 2 participants."); } // Check DM restrictions: participants with "only allow DMs from friends" setting foreach (var participantId in allParticipantIds.Where(id => id != creatorId)) { if (await _relationService.ShouldBlockDmAsync(participantId, creatorId)) { var blockedUser = await _context.Users.FindAsync(participantId); var blockedName = blockedUser?.UserName ?? "A user"; _logger.LogWarning("User {ParticipantId} only accepts DMs from friends, blocking conversation from {CreatorId}", participantId, creatorId); return (null, $"{blockedName} only accepts messages from friends."); } } var conversation = new Conversation { Id = Guid.NewGuid(), Title = title, CreatorId = creatorId, Creator = creator, CreatedAt = DateTime.UtcNow, LastMessageAt = DateTime.UtcNow, MessageCount = 0 }; _context.Conversations.Add(conversation); // Add participants foreach (var participantId in allParticipantIds) { var participant = new ConversationParticipant { ConversationId = conversation.Id, UserId = participantId, JoinedAt = DateTime.UtcNow, LastReadAt = participantId == creatorId ? DateTime.UtcNow : null }; _context.ConversationParticipants.Add(participant); } await _context.SaveChangesAsync(); // Create the initial message var message = await _messageService.CreateMessageAsync(conversation.Id, creatorId, initialMessage); if (message == null) { _logger.LogError("Failed to create initial message for conversation {ConversationId}", conversation.Id); // Rollback conversation _context.Conversations.Remove(conversation); await _context.SaveChangesAsync(); return (null, "Failed to create initial message."); } _logger.LogInformation("Conversation {ConversationId} created by user {CreatorId} with {ParticipantCount} participants", conversation.Id, creatorId, allParticipantIds.Count); // Reload with navigation properties return (await GetConversationByIdAsync(conversation.Id, creatorId), null); } public async Task GetConversationByIdAsync(Guid conversationId, Guid requestingUserId) { var conversation = await _context.Conversations .Include(c => c.Creator) .Include(c => c.Participants) .ThenInclude(p => p.User) .AsSplitQuery() .FirstOrDefaultAsync(c => c.Id == conversationId); if (conversation == null) { return null; } // Verify requester is a participant (active or left) var isParticipant = conversation.Participants.Any(p => p.UserId == requestingUserId); if (!isParticipant) { _logger.LogWarning("User {UserId} attempted to access conversation {ConversationId} without being a participant", requestingUserId, conversationId); return null; } // Fetch last message separately var lastMessage = await _context.Messages .Include(m => m.Author) .Where(m => m.ConversationId == conversationId) .OrderByDescending(m => m.CreatedAt) .FirstOrDefaultAsync(); if (lastMessage != null) { conversation.Messages = [lastMessage]; } return conversation; } public async Task<(IEnumerable Items, int TotalCount)> GetUserConversationsAsync(Guid userId, int page, int pageSize) { // Get total count first (without includes for performance) var totalCount = await _context.ConversationParticipants .Where(p => p.UserId == userId && !p.HasLeft) .CountAsync(); // Get conversation IDs for this page var conversationIds = await _context.ConversationParticipants .Where(p => p.UserId == userId && !p.HasLeft) .Select(p => p.ConversationId) .Join(_context.Conversations, id => id, c => c.Id, (id, c) => c) .OrderByDescending(c => c.LastMessageAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(c => c.Id) .ToListAsync(); // Fetch full conversations with includes var conversations = await _context.Conversations .Include(c => c.Creator) .Include(c => c.Participants) .ThenInclude(p => p.User) .Where(c => conversationIds.Contains(c.Id)) .OrderByDescending(c => c.LastMessageAt) .AsSplitQuery() .ToListAsync(); // Fetch last message for each conversation using a lateral join approach var lastMessageIds = await _context.Messages .Where(m => conversationIds.Contains(m.ConversationId)) .GroupBy(m => m.ConversationId) .Select(g => g.OrderByDescending(m => m.CreatedAt).Select(m => m.Id).First()) .ToListAsync(); var lastMessages = await _context.Messages .Include(m => m.Author) .Where(m => lastMessageIds.Contains(m.Id)) .ToListAsync(); // Attach last messages to conversations foreach (var conversation in conversations) { var lastMessage = lastMessages.FirstOrDefault(m => m.ConversationId == conversation.Id); if (lastMessage != null) { conversation.Messages = [lastMessage]; } } return (conversations, totalCount); } public async Task GetUnreadConversationCountAsync(Guid userId) { return await _context.ConversationParticipants .Where(p => p.UserId == userId && !p.HasLeft) .Where(p => p.LastReadAt == null || p.Conversation.LastMessageAt > p.LastReadAt) .CountAsync(); } public async Task AddParticipantAsync(Guid conversationId, Guid addedByUserId, Guid newParticipantId) { var conversation = await _context.Conversations .Include(c => c.Participants) .FirstOrDefaultAsync(c => c.Id == conversationId); if (conversation == null) { _logger.LogWarning("Conversation {ConversationId} not found when adding participant", conversationId); return false; } // Verify adder is an active participant var adder = conversation.Participants.FirstOrDefault(p => p.UserId == addedByUserId && !p.HasLeft); if (adder == null) { _logger.LogWarning("User {UserId} is not an active participant of conversation {ConversationId}", addedByUserId, conversationId); return false; } // Verify new user exists var newUser = await _context.Users.FindAsync(newParticipantId); if (newUser == null) { _logger.LogWarning("User {UserId} not found when adding to conversation", newParticipantId); return false; } // Check if already a participant var existingParticipant = conversation.Participants.FirstOrDefault(p => p.UserId == newParticipantId); if (existingParticipant != null) { if (!existingParticipant.HasLeft) { // Already an active participant return true; } // Re-join: reset HasLeft and LeftAt existingParticipant.HasLeft = false; existingParticipant.LeftAt = null; existingParticipant.JoinedAt = DateTime.UtcNow; } else { // Add new participant var participant = new ConversationParticipant { ConversationId = conversationId, UserId = newParticipantId, JoinedAt = DateTime.UtcNow }; _context.ConversationParticipants.Add(participant); } await _context.SaveChangesAsync(); _logger.LogInformation("User {NewUserId} added to conversation {ConversationId} by user {AddedByUserId}", newParticipantId, conversationId, addedByUserId); return true; } public async Task RemoveParticipantAsync(Guid conversationId, Guid removedByUserId, Guid removedParticipantId) { var conversation = await _context.Conversations .Include(c => c.Participants) .FirstOrDefaultAsync(c => c.Id == conversationId); if (conversation == null) { _logger.LogWarning("Conversation {ConversationId} not found when removing participant", conversationId); return false; } // Verify remover is an active participant var remover = conversation.Participants.FirstOrDefault(p => p.UserId == removedByUserId && !p.HasLeft); if (remover == null) { _logger.LogWarning("User {UserId} is not an active participant of conversation {ConversationId}", removedByUserId, conversationId); return false; } // Verify the removed participant is actually present var removedParticipant = conversation.Participants.FirstOrDefault(p => p.UserId == removedParticipantId && !p.HasLeft); if (removedParticipant == null) { _logger.LogWarning("User {UserId} is not an active participant of conversation {ConversationId}", removedParticipantId, conversationId); return false; } _context.ConversationParticipants.Remove(removedParticipant); await _context.SaveChangesAsync(); await RemoveParticipantFromVoiceAsync(conversationId, removedParticipantId); _logger.LogInformation("User {removedParticipantId} removed from conversation {ConversationId} by user {removedByUserId}", removedParticipantId, conversationId, removedByUserId); return true; } public async Task LeaveConversationAsync(Guid conversationId, Guid userId) { var participant = await _context.ConversationParticipants .FirstOrDefaultAsync(p => p.ConversationId == conversationId && p.UserId == userId && !p.HasLeft); if (participant == null) { _logger.LogWarning("User {UserId} is not an active participant of conversation {ConversationId}", userId, conversationId); return false; } participant.HasLeft = true; participant.LeftAt = DateTime.UtcNow; await _context.SaveChangesAsync(); await RemoveParticipantFromVoiceAsync(conversationId, userId); _logger.LogInformation("User {UserId} left conversation {ConversationId}", userId, conversationId); return true; } public async Task IsParticipantAsync(Guid conversationId, Guid userId) { return await _context.ConversationParticipants .AnyAsync(p => p.ConversationId == conversationId && p.UserId == userId && !p.HasLeft); } public async Task MarkAsReadAsync(Guid conversationId, Guid userId) { var participant = await _context.ConversationParticipants .FirstOrDefaultAsync(p => p.ConversationId == conversationId && p.UserId == userId && !p.HasLeft); if (participant != null) { participant.LastReadAt = DateTime.UtcNow; await _context.SaveChangesAsync(); } } public async Task MarkAllAsReadAsync(Guid userId) { var now = DateTime.UtcNow; await _context.ConversationParticipants .Where(p => p.UserId == userId && !p.HasLeft) .Where(p => p.LastReadAt == null || p.Conversation.LastMessageAt > p.LastReadAt) .ExecuteUpdateAsync(s => s.SetProperty(p => p.LastReadAt, now)); } public async Task LockConversationAsync(Guid conversationId, bool locked) { var conversation = await _context.Conversations.FindAsync(conversationId); if (conversation == null) { _logger.LogWarning("Conversation {ConversationId} not found when locking", conversationId); return false; } conversation.IsLocked = locked; await _context.SaveChangesAsync(); _logger.LogInformation("Conversation {ConversationId} {Action}", conversationId, locked ? "locked" : "unlocked"); return true; } public async Task DeleteConversationAsync(Guid conversationId) { var conversation = await _context.Conversations .Include(c => c.Messages) .ThenInclude(m => m.Mentions) .Include(c => c.Participants) .FirstOrDefaultAsync(c => c.Id == conversationId); if (conversation == null) { _logger.LogWarning("Conversation {ConversationId} not found when deleting", conversationId); return false; } // Bulk-delete all notifications associated with this conversation await _context.Notifications .Where(n => n.RelatedConversationId == conversationId) .ExecuteDeleteAsync(); var participantIds = conversation.Participants .Select(participant => participant.UserId) .Distinct() .ToList(); _context.Conversations.Remove(conversation); await _context.SaveChangesAsync(); await RemoveParticipantsFromVoiceAsync(conversationId, participantIds); _logger.LogInformation("Conversation {ConversationId} deleted", conversationId); return true; } private async Task RemoveParticipantFromVoiceAsync(Guid conversationId, Guid participantId) { if (!_roomAdminClient.IsEnabled) { return; } await _roomAdminClient.RemoveParticipantAsync( ConversationVoiceService.GetRoomName(conversationId), participantId.ToString()); } private async Task RemoveParticipantsFromVoiceAsync(Guid conversationId, IEnumerable participantIds) { if (!_roomAdminClient.IsEnabled) { return; } var roomName = ConversationVoiceService.GetRoomName(conversationId); foreach (var participantId in participantIds.Distinct()) { await _roomAdminClient.RemoveParticipantAsync(roomName, participantId.ToString()); } } } }