Ответы и реакции истории, публикации в профиле. Фиксы
This commit is contained in:
@@ -6,6 +6,10 @@ 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;
|
||||
|
||||
@@ -17,18 +21,30 @@ public sealed class StoriesController : ControllerBase
|
||||
private readonly IdentityDbContext _context;
|
||||
private readonly IUserContext _userContext;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly Microsoft.AspNetCore.SignalR.IHubContext<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> _hubContext;
|
||||
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,
|
||||
Microsoft.AspNetCore.SignalR.IHubContext<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> hubContext)
|
||||
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]
|
||||
@@ -45,6 +61,8 @@ public sealed class StoriesController : ControllerBase
|
||||
|
||||
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);
|
||||
@@ -83,7 +101,15 @@ public sealed class StoriesController : ControllerBase
|
||||
createdAt = s.CreatedAt,
|
||||
expiresAt = s.ExpiresAt,
|
||||
viewCount = s.Viewers.Count,
|
||||
viewed = s.Viewers.Any(v => v.UserId == currentUserId)
|
||||
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))
|
||||
});
|
||||
@@ -115,26 +141,94 @@ public sealed class StoriesController : ControllerBase
|
||||
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) return NotFound();
|
||||
if (story.UserId == _userContext.UserId) return Ok(new { message = "Owner view" });
|
||||
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 targetedly
|
||||
// Notify owner via SignalR - send to all, filter on client
|
||||
var viewer = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
|
||||
await _hubContext.Clients.User(story.UserId.ToString()).SendAsync("story_viewed", new
|
||||
|
||||
_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,
|
||||
@@ -142,20 +236,27 @@ public sealed class StoriesController : ControllerBase
|
||||
displayName = viewer?.DisplayName,
|
||||
avatar = viewer?.Avatar,
|
||||
viewedAt = DateTime.UtcNow,
|
||||
viewCount = story.Viewers.Count,
|
||||
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)
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
// Likely a unique constraint violation (already viewed)
|
||||
_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);
|
||||
}
|
||||
}
|
||||
@@ -163,12 +264,25 @@ public sealed class StoriesController : ControllerBase
|
||||
[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) return NotFound();
|
||||
if (story.UserId != _userContext.UserId) return Forbid();
|
||||
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>();
|
||||
@@ -190,9 +304,213 @@ public sealed class StoriesController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
_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)
|
||||
{
|
||||
@@ -205,6 +523,22 @@ public sealed class StoriesController : ControllerBase
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user