Files
forkmessager/apps/server-net/src/Vortex.Host/Controllers/StoriesController.cs

545 lines
20 KiB
C#

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<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> _hubContext;
private readonly IMessageRepository _messageRepository;
private readonly ILogger<StoriesController> _logger;
public StoriesController(
IdentityDbContext context,
IUserContext userContext,
IUserRepository userRepository,
IChatRepository chatRepository,
IMessageRepository messageRepository,
ISender sender,
IHubContext<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> hubContext,
ILogger<StoriesController> logger)
{
_context = context;
_userContext = userContext;
_userRepository = userRepository;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_sender = sender;
_hubContext = hubContext;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> GetStories(CancellationToken ct)
{
var currentUserId = _userContext.UserId;
var friendships = await _context.Set<Friendship>()
.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<object>();
var userIds = groups.Select(g => g.Key).ToList();
var userMap = new Dictionary<Guid, dynamic>();
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<object>();
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<object>();
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<IActionResult> 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<Guid> 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<Guid> { 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);