545 lines
20 KiB
C#
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);
|