using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Vortex.Modules.Identity.Domain; using Vortex.Modules.Identity.Infrastructure.Persistence; using Vortex.Shared.Kernel; using Vortex.Modules.Identity.Application.Abstractions; using Microsoft.Extensions.Logging; using MediatR; using Vortex.Modules.Chats.Domain; using Vortex.Modules.Chats.Application.Messages.Send; namespace Vortex.Host.Controllers; [Authorize] [ApiController] [Route("api/stories")] public sealed class StoriesController : ControllerBase { private readonly IdentityDbContext _context; private readonly IUserContext _userContext; private readonly IUserRepository _userRepository; private readonly IChatRepository _chatRepository; private readonly ISender _sender; private readonly IHubContext _hubContext; private readonly IMessageRepository _messageRepository; private readonly ILogger _logger; public StoriesController( IdentityDbContext context, IUserContext userContext, IUserRepository userRepository, IChatRepository chatRepository, IMessageRepository messageRepository, ISender sender, IHubContext hubContext, ILogger logger) { _context = context; _userContext = userContext; _userRepository = userRepository; _chatRepository = chatRepository; _messageRepository = messageRepository; _sender = sender; _hubContext = hubContext; _logger = logger; } [HttpGet] public async Task GetStories(CancellationToken ct) { var currentUserId = _userContext.UserId; var friendships = await _context.Set() .Where(f => f.Status == FriendshipStatus.Accepted && (f.UserId == currentUserId || f.FriendId == currentUserId)) .ToListAsync(ct); var friendIds = friendships.Select(f => f.UserId == currentUserId ? f.FriendId : f.UserId).ToList(); friendIds.Add(currentUserId); var stories = await _context.Stories .Include(s => s.Viewers) .Include(s => s.Reactions) .Include(s => s.Replies) .Where(s => s.ExpiresAt > DateTime.UtcNow && friendIds.Contains(s.UserId)) .OrderByDescending(s => s.CreatedAt) .ToListAsync(ct); var groups = stories.GroupBy(s => s.UserId).ToList(); var result = new List(); var userIds = groups.Select(g => g.Key).ToList(); var userMap = new Dictionary(); foreach (var uid in userIds) { var u = await _userRepository.GetByIdAsync(uid, ct); if (u != null) userMap[uid] = u; } foreach (var group in groups) { if (!userMap.TryGetValue(group.Key, out var user)) continue; result.Add(new { user = new { id = user.Id, username = user.Username, displayName = user.DisplayName, avatar = user.Avatar }, stories = group.Select(s => new { id = s.Id, type = s.Type, mediaUrl = s.MediaUrl, content = s.Content, bgColor = s.BgColor, createdAt = s.CreatedAt, expiresAt = s.ExpiresAt, viewCount = s.Viewers.Count, viewed = s.Viewers.Any(v => v.UserId == currentUserId), reactions = s.Reactions.Select(r => new { id = r.Id, userId = r.UserId, emoji = r.Emoji, createdAt = r.CreatedAt }).ToList(), replyCount = s.Replies.Count }).OrderBy(s => s.createdAt).ToList(), hasUnviewed = group.Any(s => !s.Viewers.Any(v => v.UserId == currentUserId)) }); } var finalResult = result.OrderBy(r => { var rDynamic = (dynamic)r; if ((Guid)rDynamic.user.id == currentUserId) return 0; return 1; }).ToList(); return Ok(finalResult); } [HttpPost] public async Task CreateStory([FromBody] CreateStoryRequest request, CancellationToken ct) { var story = Story.Create( _userContext.UserId, request.Type, request.MediaUrl, request.Content, request.BgColor); _context.Stories.Add(story); await _context.SaveChangesAsync(ct); return Ok(new { id = story.Id }); } [HttpGet("user/{userId}")] public async Task GetUserStories(Guid userId, CancellationToken ct) { var currentUserId = _userContext.UserId; var stories = await _context.Stories .Include(s => s.Viewers) .Include(s => s.Reactions) .Include(s => s.Replies) .Where(s => s.UserId == userId) .OrderByDescending(s => s.CreatedAt) .ToListAsync(ct); var user = await _userRepository.GetByIdAsync(userId, ct); if (user == null) return NotFound(); var result = new { user = new { id = user.Id, username = user.Username, displayName = user.DisplayName, avatar = user.Avatar }, stories = stories.Select(s => new { id = s.Id, type = s.Type, mediaUrl = s.MediaUrl, content = s.Content, bgColor = s.BgColor, createdAt = s.CreatedAt, expiresAt = s.ExpiresAt, viewCount = s.Viewers.Count, viewed = s.Viewers.Any(v => v.UserId == currentUserId), reactions = s.Reactions.Select(r => new { id = r.Id, userId = r.UserId, emoji = r.Emoji, createdAt = r.CreatedAt }).ToList(), replyCount = s.Replies.Count }).ToList() }; return Ok(result); } [HttpPost("{id}/view")] public async Task ViewStory(Guid id, CancellationToken ct) { _logger.LogInformation("ViewStory called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId); try { var story = await _context.Stories .Include(s => s.Viewers) .FirstOrDefaultAsync(s => s.Id == id, ct); if (story == null) { _logger.LogWarning("Story not found: {StoryId}", id); return NotFound(); } if (story.UserId == _userContext.UserId) { _logger.LogInformation("Owner view, skipping"); return Ok(new { message = "Owner view" }); } if (!story.Viewers.Any(v => v.UserId == _userContext.UserId)) { story.AddViewer(_userContext.UserId); await _context.SaveChangesAsync(ct); _logger.LogInformation("Viewer added. Total viewers: {Count}", story.Viewers.Count); // Notify owner via SignalR - send to all, filter on client var viewer = await _userRepository.GetByIdAsync(_userContext.UserId, ct); _logger.LogInformation("Sending story_viewed to owner {OwnerId}", story.UserId); // Get updated story with viewers to be sure count is accurate var updatedStory = await _context.Stories.Include(s => s.Viewers).FirstAsync(s => s.Id == story.Id, ct); await _hubContext.Clients.All.SendAsync("story_viewed", new { storyId = story.Id, userId = _userContext.UserId, username = viewer?.Username, displayName = viewer?.DisplayName, avatar = viewer?.Avatar, viewedAt = DateTime.UtcNow, viewCount = updatedStory.Viewers.Count, ownerId = story.UserId }, ct); _logger.LogInformation("story_viewed sent successfully"); } else { _logger.LogInformation("User already viewed this story"); } return Ok(new { message = "Story viewed" }); } catch (DbUpdateException ex) { _logger.LogError(ex, "DbUpdateException in ViewStory"); return Ok(new { message = "Story already viewed" }); } catch (Exception ex) { _logger.LogError(ex, "ViewStory error"); return StatusCode(500, ex.Message); } } [HttpGet("{id}/viewers")] public async Task GetStoryViewers(Guid id, CancellationToken ct) { _logger.LogInformation("GetStoryViewers called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId); var story = await _context.Stories .Include(s => s.Viewers) .FirstOrDefaultAsync(s => s.Id == id, ct); if (story == null) { _logger.LogWarning("Story not found: {StoryId}", id); return NotFound(); } if (story.UserId != _userContext.UserId) { _logger.LogWarning("Forbidden: User {UserId} is not owner of story {StoryId}", _userContext.UserId, id); return Forbid(); } _logger.LogInformation("Story has {Count} viewers", story.Viewers.Count); var viewerIds = story.Viewers.Select(v => v.UserId).ToList(); var viewers = new List(); foreach (var viewerId in viewerIds) { var user = await _userRepository.GetByIdAsync(viewerId, ct); if (user == null) continue; var viewerRecord = story.Viewers.First(v => v.UserId == viewerId); viewers.Add(new { userId = user.Id, username = user.Username, displayName = user.DisplayName, avatar = user.Avatar, viewedAt = viewerRecord.ViewedAt }); } _logger.LogInformation("Returning {Count} viewers", viewers.Count); return Ok(viewers); } private string GetStoryQuote(Story story) { if (!string.IsNullOrEmpty(story.Content)) return story.Content; return story.Type.ToLower() switch { "image" => "🖼 Фото", "video" => "🎬 Видео", _ => "История" }; } [HttpPost("{id}/reaction")] public async Task AddReaction(Guid id, [FromBody] AddStoryReactionRequest request, CancellationToken ct) { _logger.LogInformation("AddReaction called: StoryId={StoryId}, UserId={UserId}, Emoji={Emoji}", id, _userContext.UserId, request.Emoji); try { var story = await _context.Stories.FindAsync(new object[] { id }, ct); if (story == null) { _logger.LogWarning("Story not found: {StoryId}", id); return NotFound(); } // Check if reaction already exists var existing = await _context.StoryReactions .FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct); if (existing != null) { _logger.LogInformation("Reaction already exists"); return Ok(new { message = "Reaction already exists" }); } // Add reaction directly via DbSet var reaction = new StoryReaction(id, _userContext.UserId, request.Emoji); _context.StoryReactions.Add(reaction); await _context.SaveChangesAsync(ct); _logger.LogInformation("Reaction saved successfully"); // 1. Create/Find chat var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct); // 2. Threading: find last story message in this chat var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct); if (lastStoryMessage != null) { // If message already exists for this story, add a reaction to it var addReactionCommand = new Vortex.Modules.Chats.Application.Messages.React.AddReactionCommand( lastStoryMessage.Id, _userContext.UserId, request.Emoji, chatId); await _sender.Send(addReactionCommand, ct); } else { // Create new message for the story var storyQuote = GetStoryQuote(story); var messageCommand = new SendMessageCommand( ChatId: chatId, SenderId: _userContext.UserId, Content: request.Emoji, Type: "text", Quote: storyQuote, StoryId: story.Id, StoryMediaUrl: story.MediaUrl, StoryMediaType: story.Type); await _sender.Send(messageCommand, ct); } // 3. Notify story owner in real-time (existing StoryViewer listeners) var reactor = await _userRepository.GetByIdAsync(_userContext.UserId, ct); await _hubContext.Clients.All.SendAsync("story_reaction", new { storyId = story.Id, userId = _userContext.UserId, username = reactor?.Username, displayName = reactor?.DisplayName, avatar = reactor?.Avatar, emoji = request.Emoji, createdAt = DateTime.UtcNow, ownerId = story.UserId }, ct); return Ok(new { message = "Reaction added" }); } catch (Exception ex) { _logger.LogError(ex, "AddReaction error"); return StatusCode(500, ex.Message); } } [HttpDelete("{id}/reaction")] public async Task RemoveReaction(Guid id, [FromBody] RemoveStoryReactionRequest request, CancellationToken ct) { var reaction = await _context.StoryReactions .FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct); if (reaction == null) return Ok(new { message = "Reaction not found" }); _context.StoryReactions.Remove(reaction); await _context.SaveChangesAsync(ct); return Ok(new { message = "Reaction removed" }); } [HttpPost("{id}/reply")] public async Task AddReply(Guid id, [FromBody] AddStoryReplyRequest request, CancellationToken ct) { _logger.LogInformation("AddReply called: StoryId={StoryId}, UserId={UserId}, Content={Content}", id, _userContext.UserId, request.Content); try { var story = await _context.Stories.FindAsync(new object[] { id }, ct); if (story == null) { _logger.LogWarning("Story not found: {StoryId}", id); return NotFound(); } // Add reply directly via DbSet var reply = new StoryReply(id, _userContext.UserId, request.Content); _context.StoryReplies.Add(reply); await _context.SaveChangesAsync(ct); _logger.LogInformation("Reply saved successfully"); // 1. Create/Find chat var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct); // 2. Find last message for threading var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct); // 3. Send message to chat var storyQuote = GetStoryQuote(story); var messageCommand = new SendMessageCommand( ChatId: chatId, SenderId: _userContext.UserId, Content: request.Content, Type: "text", Quote: storyQuote, ReplyToId: lastStoryMessage?.Id, StoryId: story.Id, StoryMediaUrl: story.MediaUrl, StoryMediaType: story.Type); await _sender.Send(messageCommand, ct); // 3. Notify story owner in real-time (existing StoryViewer listeners) var replier = await _userRepository.GetByIdAsync(_userContext.UserId, ct); await _hubContext.Clients.All.SendAsync("story_reply", new { storyId = story.Id, userId = _userContext.UserId, username = replier?.Username, displayName = replier?.DisplayName, avatar = replier?.Avatar, content = request.Content, createdAt = DateTime.UtcNow, ownerId = story.UserId }, ct); return Ok(new { message = "Reply added" }); } catch (Exception ex) { _logger.LogError(ex, "AddReply error"); return StatusCode(500, ex.Message); } } [HttpGet("{id}/replies")] public async Task GetReplies(Guid id, CancellationToken ct) { var story = await _context.Stories .Include(s => s.Replies) .FirstOrDefaultAsync(s => s.Id == id, ct); if (story == null) return NotFound(); if (story.UserId != _userContext.UserId) return Forbid(); var replies = new List(); foreach (var reply in story.Replies.OrderBy(r => r.CreatedAt)) { var user = await _userRepository.GetByIdAsync(reply.UserId, ct); if (user == null) continue; replies.Add(new { id = reply.Id, userId = user.Id, username = user.Username, displayName = user.DisplayName, avatar = user.Avatar, content = reply.Content, createdAt = reply.CreatedAt }); } return Ok(replies); } [HttpDelete("{id}")] public async Task DeleteStory(Guid id, CancellationToken ct) { var story = await _context.Stories.FindAsync(new object[] { id }, ct); if (story == null) return NotFound(); if (story.UserId != _userContext.UserId) return Forbid(); _context.Stories.Remove(story); await _context.SaveChangesAsync(ct); return Ok(new { message = "Story deleted" }); } private async Task GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken ct) { var userChats = await _chatRepository.GetUserChatsAsync(userId1, ct); var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == userId2)); if (personalChat != null) return personalChat.Id; var command = new Vortex.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List { userId1, userId2 }); var result = await _sender.Send(command, ct); return result.Value; } } public sealed record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor); public sealed record AddStoryReactionRequest(string Emoji); public sealed record RemoveStoryReactionRequest(string Emoji); public sealed record AddStoryReplyRequest(string Content);