Рефакторинг и монго

This commit is contained in:
Халимов Рустам
2026-03-19 16:01:51 +03:00
parent d61dfd217c
commit b2e454616d
58 changed files with 1087 additions and 3802 deletions
@@ -8,6 +8,8 @@ using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage; using Knot.Shared.Kernel.Storage;
using Knot.Modules.Identity.Infrastructure.Persistence; using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence; using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Host.Application.Admin.Commands; namespace Host.Application.Admin.Commands;
@@ -17,33 +19,36 @@ public record CleanRunCommand(IFileStorageService FileStorage, IdentityDbContext
internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse> internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
{ {
private readonly ChatsDbContext _chatsDbContext; private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanRunCommandHandler(ChatsDbContext chatsDbContext) public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{ {
_chatsDbContext = chatsDbContext; _chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("Messages");
} }
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken) public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
{ {
var orphanMessages = await _chatsDbContext.Messages var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
.Include(m => m.Media) var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
.Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(cancellationToken); var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList(); var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var keptMessages = await _chatsDbContext.Messages
.AsNoTracking()
.Include(m => m.Media)
.Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(cancellationToken);
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken); var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>(); var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null) .Where(m => m.Media != null)
.SelectMany(m => m.Media) .SelectMany(m => m.Media)
.Select(me => me.Url) .Select(me => me.Url)
@@ -85,8 +90,9 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
if (orphanMessages.Any()) if (orphanMessages.Any())
{ {
_chatsDbContext.Messages.RemoveRange(orphanMessages); var orphanIds = orphanMessages.Select(m => m.Id).ToList();
await _chatsDbContext.SaveChangesAsync(cancellationToken); var filter = Builders<Message>.Filter.In(m => m.Id, orphanIds);
await _messages.DeleteManyAsync(filter, cancellationToken);
} }
return Result.Success(new MessageResponse("Cleanup completed successfully")); return Result.Success(new MessageResponse("Cleanup completed successfully"));
@@ -8,6 +8,8 @@ using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage; using Knot.Shared.Kernel.Storage;
using Knot.Modules.Identity.Infrastructure.Persistence; using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence; using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Host.Models; using Host.Models;
@@ -18,35 +20,38 @@ public record CleanDryRunQuery(IFileStorageService FileStorage, IdentityDbContex
internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto> internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto>
{ {
private readonly ChatsDbContext _chatsDbContext; private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext) public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{ {
_chatsDbContext = chatsDbContext; _chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("Messages");
} }
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken) public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
{ {
var orphanedMessages = await _chatsDbContext.Messages var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
.Include(m => m.Media) var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
.Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(cancellationToken); var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanedMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var orphanedMessagesCount = orphanedMessages.Count; var orphanedMessagesCount = orphanedMessages.Count;
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList(); var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var keptMessages = await _chatsDbContext.Messages
.AsNoTracking()
.Include(m => m.Media)
.Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(cancellationToken);
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken); var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>(); var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null) .Where(m => m.Media != null)
.SelectMany(m => m.Media) .SelectMany(m => m.Media)
.Select(me => me.Url) .Select(me => me.Url)
@@ -6,8 +6,8 @@ using MediatR;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Host.Models; using Host.Models;
using Knot.Modules.Identity.Domain; using Knot.Modules.Identity.Domain;
using Knot.Modules.Chats.Infrastructure.Persistence; using Knot.Modules.Chats.Domain;
using Microsoft.EntityFrameworkCore; using MongoDB.Driver;
namespace Host.Application.Admin.Queries; namespace Host.Application.Admin.Queries;
@@ -16,12 +16,12 @@ public record GetUserDetailsQuery(Guid UserId) : IQuery<AdminUserDetailsDto>;
internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDetailsDto> internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDetailsDto>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly ChatsDbContext _chatsDbContext; private readonly IMongoCollection<Message> _messages;
public GetUserDetailsQueryHandler(IUserRepository userRepository, ChatsDbContext chatsDbContext) public GetUserDetailsQueryHandler(IUserRepository userRepository, IMongoDatabase mongoDatabase)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_chatsDbContext = chatsDbContext; _messages = mongoDatabase.GetCollection<Message>("Messages");
} }
public async Task<Result<AdminUserDetailsDto>> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken) public async Task<Result<AdminUserDetailsDto>> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken)
@@ -32,23 +32,20 @@ internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQ
return Result.Failure<AdminUserDetailsDto>(IdentityErrors.UserNotFound); return Result.Failure<AdminUserDetailsDto>(IdentityErrors.UserNotFound);
} }
var messagesCount = await _chatsDbContext.Messages.CountAsync(m => m.SenderId == request.UserId, cancellationToken); var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId);
var userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
var allUserMedia = await _chatsDbContext.Messages var messagesCount = userMessages.Count;
.AsNoTracking()
.Where(m => m.SenderId == request.UserId) var allUserMedia = userMessages.OfType<MediaMessage>().SelectMany(m => m.Media).ToList();
.SelectMany(m => m.Media)
.ToListAsync(cancellationToken);
var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video"); var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video");
var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio"); var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio");
var storageUsed = allUserMedia.Sum(m => m.Size ?? 0); var storageUsed = allUserMedia.Sum(m => m.Size ?? 0);
var userContents = await _chatsDbContext.Messages var userContents = userMessages.OfType<TextMessage>().Select(m => m.Content)
.AsNoTracking() .Concat(userMessages.OfType<MediaMessage>().Where(m => m.Caption != null).Select(m => m.Caption))
.Where(m => m.SenderId == request.UserId) .ToList();
.Select(m => m.Content)
.ToListAsync(cancellationToken);
var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http")); var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http"));
+5
View File
@@ -27,6 +27,7 @@ var builder = WebApplication.CreateBuilder(args);
var envMappings = new Dictionary<string, string?> var envMappings = new Dictionary<string, string?>
{ {
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"], ["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"],
["ConnectionStrings:MongoConnection"] = builder.Configuration["MONGO_CONNECTION"],
["Jwt:Secret"] = builder.Configuration["JWT_SECRET"], ["Jwt:Secret"] = builder.Configuration["JWT_SECRET"],
["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"], ["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"],
["Jwt:Audience"] = builder.Configuration["JWT_AUDIENCE"], ["Jwt:Audience"] = builder.Configuration["JWT_AUDIENCE"],
@@ -160,6 +161,10 @@ using (var scope = app.Services.CreateScope())
var systemDb = scope.ServiceProvider.GetRequiredService<Knot.Shared.Infrastructure.Persistence.SystemDbContext>(); var systemDb = scope.ServiceProvider.GetRequiredService<Knot.Shared.Infrastructure.Persistence.SystemDbContext>();
await systemDb.Database.MigrateAsync(); await systemDb.Database.MigrateAsync();
// Set Encryption Service for MongoDB serializers
var encryptionService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Security.IEncryptionService>();
Knot.Modules.Chats.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService;
// Initialize Global Settings Cache // Initialize Global Settings Cache
var settingsService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Configuration.ISettingsService>(); var settingsService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Configuration.ISettingsService>();
if (settingsService is Knot.Shared.Infrastructure.Configuration.SettingsService concreteSettings) if (settingsService is Knot.Shared.Infrastructure.Configuration.SettingsService concreteSettings)
@@ -40,33 +40,32 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
} }
var userIdsToFetch = new HashSet<Guid>(); var userIdsToFetch = new HashSet<Guid>();
foreach (var m in chat.Members) foreach (var member in chat.Members)
{ {
userIdsToFetch.Add(m.UserId); userIdsToFetch.Add(member.UserId);
} }
var chatMessages = await _messageRepository.GetChatMessagesAsync(chat.Id, 1, 0, cancellationToken); var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
var mFirst = chatMessages.FirstOrDefault(); if (latestMessage != null)
if (mFirst != null)
{ {
userIdsToFetch.Add(mFirst.SenderId); userIdsToFetch.Add(latestMessage.SenderId);
foreach (var r in mFirst.Reactions) foreach (var reaction in latestMessage.Reactions)
{ {
userIdsToFetch.Add(r.UserId); userIdsToFetch.Add(reaction.UserId);
} }
} }
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken); var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var members = new List<ChatMemberDto>(); var members = new List<ChatMemberDto>();
foreach (var m in chat.Members) foreach (var member in chat.Members)
{ {
usersInfo.TryGetValue(m.UserId, out var user); usersInfo.TryGetValue(member.UserId, out var user);
members.Add(new ChatMemberDto( members.Add(new ChatMemberDto(
m.Id, member.Id,
m.UserId, member.UserId,
m.Role, member.Role,
m.IsPinned, member.IsPinned,
user != null ? new ChatUserDto( user != null ? new ChatUserDto(
user.Id, user.Id,
user.Username, user.Username,
@@ -79,48 +78,47 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
} }
var messagesList = new List<ChatMessageDto>(); var messagesList = new List<ChatMessageDto>();
if (chatMessages.Any()) if (latestMessage != null)
{ {
var m = mFirst; usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
usersInfo.TryGetValue(m.SenderId, out var senderObj);
var reactionsWithUser = new List<ReactionDto>(); var reactionsWithUser = new List<ReactionDto>();
foreach (var r in m.Reactions) foreach (var reaction in latestMessage.Reactions)
{ {
usersInfo.TryGetValue(r.UserId, out var rUser); usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto( reactionsWithUser.Add(new ReactionDto(
r.Id, reaction.Id,
r.Emoji, reaction.Emoji,
r.UserId, reaction.UserId,
rUser != null reactionUser != null
? new MessageSenderDto(rUser.Id, rUser.Username, rUser.DisplayName, rUser.Avatar) ? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(r.UserId, "unknown", "Unknown", null) : new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
)); ));
} }
messagesList.Add(new ChatMessageDto( messagesList.Add(new ChatMessageDto(
m.Id, latestMessage.Id,
m.ChatId, latestMessage.ChatId,
m.SenderId, latestMessage.SenderId,
m.Content, latestMessage.Content,
m.Type, latestMessage.Type,
m.ReplyToId, latestMessage.ReplyToId,
m.Quote, latestMessage.Quote,
m.StoryId, latestMessage.StoryId,
m.StoryMediaUrl, latestMessage.StoryMediaUrl,
m.StoryMediaType, latestMessage.StoryMediaType,
m.IsEdited, latestMessage.IsEdited,
m.IsDeleted, latestMessage.IsDeleted,
m.CreatedAt, latestMessage.CreatedAt,
m.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(), latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senderObj != null ? new MessageSenderDto( senderObj != null ? new MessageSenderDto(
senderObj.Id, senderObj.Id,
senderObj.Username, senderObj.Username,
senderObj.DisplayName, senderObj.DisplayName,
senderObj.Avatar senderObj.Avatar
) : new MessageSenderDto(m.SenderId, "unknown", "Unknown", null), ) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser, reactionsWithUser,
m.ReadBy.Select(r => new ReadByDto(r.UserId)).ToList() latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
)); ));
} }
@@ -139,3 +137,4 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
return Result.Success<ChatDto?>(dto); return Result.Success<ChatDto?>(dto);
} }
} }
@@ -33,36 +33,38 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
var dtos = new List<ChatDto>(); var dtos = new List<ChatDto>();
bool hasFavorites = false; bool hasFavorites = false;
foreach (var c in userChats) foreach (var chat in userChats)
{ {
var userIdsToFetch = new HashSet<Guid>(); var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
foreach (var m in c.Members)
if (latestMessage == null)
{ {
userIdsToFetch.Add(m.UserId); continue;
} }
var chatMessages = await _messageRepository.GetChatMessagesAsync(c.Id, 1, 0, cancellationToken); var userIdsToFetch = new HashSet<Guid>();
var mFirst = chatMessages.FirstOrDefault(); foreach (var member in chat.Members)
if (mFirst != null)
{ {
userIdsToFetch.Add(mFirst.SenderId); userIdsToFetch.Add(member.UserId);
foreach (var r in mFirst.Reactions) }
userIdsToFetch.Add(latestMessage.SenderId);
foreach (var r in latestMessage.Reactions)
{ {
userIdsToFetch.Add(r.UserId); userIdsToFetch.Add(r.UserId);
} }
}
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken); var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var members = new List<ChatMemberDto>(); var members = new List<ChatMemberDto>();
foreach (var m in c.Members) foreach (var member in chat.Members)
{ {
usersInfo.TryGetValue(m.UserId, out var user); usersInfo.TryGetValue(member.UserId, out var user);
members.Add(new ChatMemberDto( members.Add(new ChatMemberDto(
m.Id, member.Id,
m.UserId, member.UserId,
m.Role, member.Role,
m.IsPinned, member.IsPinned,
user != null ? new ChatUserDto( user != null ? new ChatUserDto(
user.Id, user.Id,
user.Username, user.Username,
@@ -74,62 +76,59 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
)); ));
} }
var messagesList = new List<ChatMessageDto>(); usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
if (chatMessages.Any())
{
var m = mFirst;
usersInfo.TryGetValue(m.SenderId, out var senderObj);
var reactionsWithUser = new List<ReactionDto>(); var reactionsWithUser = new List<ReactionDto>();
foreach (var r in m.Reactions) foreach (var reaction in latestMessage.Reactions)
{ {
usersInfo.TryGetValue(r.UserId, out var rUser); usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto( reactionsWithUser.Add(new ReactionDto(
r.Id, reaction.Id,
r.Emoji, reaction.Emoji,
r.UserId, reaction.UserId,
rUser != null reactionUser != null
? new MessageSenderDto(rUser.Id, rUser.Username, rUser.DisplayName, rUser.Avatar) ? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(r.UserId, "unknown", "Unknown", null) : new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
)); ));
} }
messagesList.Add(new ChatMessageDto( var messagesList = new List<ChatMessageDto>
m.Id, {
m.ChatId, new ChatMessageDto(
m.SenderId, latestMessage.Id,
m.Content, latestMessage.ChatId,
m.Type, latestMessage.SenderId,
m.ReplyToId, latestMessage.Content,
m.Quote, latestMessage.Type,
m.StoryId, latestMessage.ReplyToId,
m.StoryMediaUrl, latestMessage.Quote,
m.StoryMediaType, latestMessage.StoryId,
m.IsEdited, latestMessage.StoryMediaUrl,
m.IsDeleted, latestMessage.StoryMediaType,
m.CreatedAt, latestMessage.IsEdited,
m.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(), latestMessage.IsDeleted,
latestMessage.CreatedAt,
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senderObj != null ? new MessageSenderDto( senderObj != null ? new MessageSenderDto(
senderObj.Id, senderObj.Id,
senderObj.Username, senderObj.Username,
senderObj.DisplayName, senderObj.DisplayName,
senderObj.Avatar senderObj.Avatar
) : new MessageSenderDto(m.SenderId, "unknown", "Unknown", null), ) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser, reactionsWithUser,
m.ReadBy.Select(r => new ReadByDto(r.UserId)).ToList() latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
)); )
} };
var unreadCount = await _messageRepository.GetUnreadCountAsync(c.Id, request.UserId, cancellationToken); var unreadCount = await _messageRepository.GetUnreadCountAsync(chat.Id, request.UserId, cancellationToken);
dtos.Add(new ChatDto( dtos.Add(new ChatDto(
c.Id, chat.Id,
c.Type.ToString().ToLowerInvariant(), chat.Type.ToString().ToLowerInvariant(),
c.Type == ChatType.Favorites ? "Избранное" : (c.Type == ChatType.Personal ? null : c.Name), chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
c.Description, chat.Description,
c.Avatar, chat.Avatar,
c.CreatedAt, chat.CreatedAt,
members, members,
messagesList, messagesList,
unreadCount unreadCount
@@ -146,3 +145,4 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
return Result.Success(sorted); return Result.Success(sorted);
} }
} }
@@ -50,9 +50,9 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
{ {
message.DeleteForUser(request.UserId); message.DeleteForUser(request.UserId);
} }
}
await _unitOfWork.SaveChangesAsync(cancellationToken); await _messageRepository.UpdateAsync(message, cancellationToken);
}
if (request.DeleteForAll) if (request.DeleteForAll)
{ {
@@ -47,46 +47,39 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
var userIdsToFetch = new HashSet<Guid>(); var userIdsToFetch = new HashSet<Guid>();
var replyMessages = new Dictionary<Guid, Message>(); var replyMessages = new Dictionary<Guid, Message>();
foreach (var m in messages) // Filter messages that are deleted for the current user before processing
var filteredMessages = messages.Where(m => !m.IsDeletedForUser(request.UserId)).ToList();
foreach (var m in filteredMessages)
{ {
if (m.DeletedByUsers.Contains(request.UserId)) userIdsToFetch.Add(m.SenderId);
if (!m.ReplyToId.HasValue)
{ {
continue; continue;
} }
userIdsToFetch.Add(m.SenderId);
if (m.ForwardedFromId.HasValue)
{
userIdsToFetch.Add(m.ForwardedFromId.Value);
}
foreach (var r in m.Reactions)
{
userIdsToFetch.Add(r.UserId);
}
if (m.ReplyToId.HasValue)
{
var replyMsg = await _messageRepository.GetByIdAsync(m.ReplyToId.Value, cancellationToken); var replyMsg = await _messageRepository.GetByIdAsync(m.ReplyToId.Value, cancellationToken);
if (replyMsg != null) if (replyMsg == null)
{ {
continue;
}
replyMessages[replyMsg.Id] = replyMsg; replyMessages[replyMsg.Id] = replyMsg;
userIdsToFetch.Add(replyMsg.SenderId); userIdsToFetch.Add(replyMsg.SenderId);
} }
}
}
var senders = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken); var senders = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
foreach (var m in messages) foreach (var message in messages)
{ {
if (m.DeletedByUsers.Contains(request.UserId)) if (message.IsDeletedForUser(request.UserId))
{ {
continue; continue;
} }
ReplyToMessageDto? replyToObj = null; ReplyToMessageDto? replyToObj = null;
if (m.ReplyToId.HasValue && replyMessages.TryGetValue(m.ReplyToId.Value, out var replyMsg)) if (message.ReplyToId.HasValue && replyMessages.TryGetValue(message.ReplyToId.Value, out var replyMsg))
{ {
var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs) var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs)
? new MessageSenderDto(rs.Id, rs.Username, rs.DisplayName, rs.Avatar) ? new MessageSenderDto(rs.Id, rs.Username, rs.DisplayName, rs.Avatar)
@@ -102,40 +95,40 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
} }
var reactionsWithUser = new List<MessageReactionDto>(); var reactionsWithUser = new List<MessageReactionDto>();
foreach (var r in m.Reactions) foreach (var reaction in message.Reactions)
{ {
var userObj = senders.TryGetValue(r.UserId, out var ru) var userObj = senders.TryGetValue(reaction.UserId, out var reactionUser)
? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) ? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(r.UserId, "unknown", "Unknown", null); : new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null);
reactionsWithUser.Add(new MessageReactionDto( reactionsWithUser.Add(new MessageReactionDto(
r.Id, reaction.Id,
r.Emoji, reaction.Emoji,
r.UserId, reaction.UserId,
userObj userObj
)); ));
} }
result.Add(new MessageDetailDto( result.Add(new MessageDetailDto(
m.Id, message.Id,
m.ChatId, message.ChatId,
m.SenderId, message.SenderId,
m.Content, message.Content,
m.Type, message.Type,
m.ReplyToId, message.ReplyToId,
replyToObj, replyToObj,
m.Quote, message.Quote,
m.IsEdited, message.IsEdited,
m.IsDeleted, message.IsDeleted,
m.CreatedAt, message.CreatedAt,
m.ForwardedFromId, message.ForwardedFromId,
m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new MessageSenderDto(fwd.Id, fwd.Username, fwd.DisplayName, fwd.Avatar) : null, message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser) ? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar) : null,
m.StoryId, message.StoryId,
m.StoryMediaUrl, message.StoryMediaUrl,
m.StoryMediaType, message.StoryMediaType,
m.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(), message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senders.TryGetValue(m.SenderId, out var s) ? new MessageSenderDto(s.Id, s.Username, s.DisplayName, s.Avatar) : null, senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : null,
m.ReadBy.Select(r => new ReadByDto(r.UserId)).ToList(), message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList(),
reactionsWithUser reactionsWithUser
)); ));
} }
@@ -143,3 +136,4 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
return Result.Success(result); return Result.Success(result);
} }
} }
@@ -31,36 +31,38 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
public async Task<Result<List<SharedMediaDto>>> Handle(GetSharedMediaQuery request, CancellationToken cancellationToken) public async Task<Result<List<SharedMediaDto>>> Handle(GetSharedMediaQuery request, CancellationToken cancellationToken)
{ {
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken); var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId)) if (chat == null || !chat.Members.Any(member => member.UserId == request.UserId))
{ {
return Result.Failure<List<SharedMediaDto>>(ChatErrors.ChatsForbidden); return Result.Failure<List<SharedMediaDto>>(ChatErrors.ChatsForbidden);
} }
var messages = await _messageRepository.GetChatMessagesAsync(request.ChatId, ChatConstants.MaxSharedMediaQueryLimit, 0, cancellationToken); var messages = await _messageRepository.GetChatMessagesAsync(request.ChatId, ChatConstants.MaxSharedMediaQueryLimit, 0, cancellationToken);
messages = messages.Where(m => !m.IsDeleted && !m.DeletedByUsers.Contains(request.UserId)).ToList(); messages = messages.Where(message => !message.IsDeletedForUser(request.UserId)).ToList();
var result = new List<SharedMediaDto>(); var result = new List<SharedMediaDto>();
var filterType = request.Type?.ToLower(); var filterType = request.Type?.ToLower();
var userIds = messages.Select(m => m.SenderId).Distinct(); var userIds = messages.Select(message => message.SenderId).Distinct();
var senders = await _userProvider.GetUsersInfoAsync(userIds, cancellationToken); var senders = await _userProvider.GetUsersInfoAsync(userIds, cancellationToken);
foreach (var m in messages) foreach (var message in messages)
{ {
if (filterType == "links") if (filterType == "links")
{ {
var messageContent = message.Content;
var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase); var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase);
var contentLinks = !string.IsNullOrEmpty(m.Content) ? linkRegex.Matches(m.Content).Select(match => match.Value).ToList() : new List<string>(); var contentLinks = !string.IsNullOrEmpty(messageContent) ? linkRegex.Matches(messageContent).Select(match => match.Value).ToList() : new List<string>();
var mediaLinks = (m.Media ?? Enumerable.Empty<Media>()).Where(media => media.Type?.ToLower() == "link").Select(media => media.Url).ToList(); var messageMediaColl = message.Media;
var mediaLinks = (messageMediaColl ?? Enumerable.Empty<Media>()).Where(media => media.Type?.ToString().ToLower() == "link").Select(media => media.Url).ToList();
var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList(); var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList();
if (allLinks.Any()) if (allLinks.Any())
{ {
senders.TryGetValue(m.SenderId, out var sender); senders.TryGetValue(message.SenderId, out var sender);
result.Add(new SharedMediaDto( result.Add(new SharedMediaDto(
m.Id, message.Id,
m.Content, messageContent,
m.CreatedAt, message.CreatedAt,
allLinks, allLinks,
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null, sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null,
null, null, null, null, null, null, null, null null, null, null, null, null, null, null, null
@@ -69,21 +71,17 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
continue; continue;
} }
if (m.Media == null || !m.Media.Any()) var messageMedia = message.Media;
if (messageMedia == null || !messageMedia.Any())
{ {
continue; continue;
} }
var filteredMedia = m.Media.Where(media => var filteredMedia = messageMedia.Where(media =>
{ {
var mediaType = media.Type?.ToLower() ?? "file"; var mediaType = media.Type?.ToLower() ?? "file";
var isGif = mediaType == "image" && (media.Url.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || media.Url.EndsWith(".gif", StringComparison.OrdinalIgnoreCase)); var isGif = mediaType == "image" && (media.Url.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || media.Url.EndsWith(".gif", StringComparison.OrdinalIgnoreCase));
if (filterType == "media")
{
return (mediaType == "image" || mediaType == "video") && !isGif;
}
if (filterType == "gifs") if (filterType == "gifs")
{ {
return isGif; return isGif;
@@ -99,20 +97,20 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
if (filteredMedia.Any()) if (filteredMedia.Any())
{ {
senders.TryGetValue(m.SenderId, out var sender); senders.TryGetValue(message.SenderId, out var sender);
result.Add(new SharedMediaDto( result.Add(new SharedMediaDto(
m.Id, message.Id,
m.Content, message.Content,
m.CreatedAt, message.CreatedAt,
null, null,
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null, sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null,
m.ReplyToId, message.ReplyToId,
m.Quote, message.Quote,
m.StoryId, message.StoryId,
m.StoryMediaUrl, message.StoryMediaUrl,
m.StoryMediaType, message.StoryMediaType,
m.IsEdited, message.IsEdited,
m.Type, message.Type,
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList()
)); ));
} }
@@ -121,3 +119,4 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
return Result.Success(result.OrderByDescending(x => x.CreatedAt).ToList()); return Result.Success(result.OrderByDescending(x => x.CreatedAt).ToList());
} }
} }
@@ -26,35 +26,36 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
public async Task<Result<List<SearchMessageDto>>> Handle(SearchMessagesQuery request, CancellationToken cancellationToken) public async Task<Result<List<SearchMessageDto>>> Handle(SearchMessagesQuery request, CancellationToken cancellationToken)
{ {
var messages = await _messageRepository.SearchMessagesAsync(request.Query, request.ChatId, request.UserId, cancellationToken); var messages = await _messageRepository.SearchMessagesAsync(request.Query, request.ChatId, request.UserId, cancellationToken);
messages = messages.Where(m => !m.DeletedByUsers.Contains(request.UserId)).ToList(); messages = messages.Where(message => !message.IsDeletedForUser(request.UserId)).ToList();
var userIds = messages.Select(m => m.SenderId).ToList(); var userIds = messages.Select(message => message.SenderId).ToList();
userIds.AddRange(messages.Where(m => m.ForwardedFromId.HasValue).Select(m => m.ForwardedFromId!.Value)); userIds.AddRange(messages.Where(message => message.ForwardedFromId.HasValue).Select(message => message.ForwardedFromId!.Value));
var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken); var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken);
var result = messages.Select(m => new SearchMessageDto( var result = messages.Select(message => new SearchMessageDto(
m.Id, message.Id,
m.ChatId, message.ChatId,
m.SenderId, message.SenderId,
m.Content, message.Content,
m.Type, message.Type,
m.ReplyToId, message.ReplyToId,
m.Quote, message.Quote,
m.IsEdited, message.IsEdited,
m.IsDeleted, message.IsDeleted,
m.CreatedAt, message.CreatedAt,
m.ForwardedFromId, message.ForwardedFromId,
m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new MessageSenderDto(fwd.Id, fwd.Username, fwd.DisplayName, fwd.Avatar) : null, null,
m.StoryId, message.StoryId,
m.StoryMediaUrl, message.StoryMediaUrl,
m.StoryMediaType, message.StoryMediaType,
m.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(), message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senders.TryGetValue(m.SenderId, out var s) ? new MessageSenderDto(s.Id, s.Username, s.DisplayName, s.Avatar) : new MessageSenderDto(m.SenderId, "unknown", "Unknown", null), senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
m.Reactions.Select(r => new SimpleReactionDto(r.UserId, r.Emoji)).ToList(), message.Reactions.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList(),
m.ReadBy.Select(r => new ReadByDto(r.UserId)).ToList() message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
)).ToList(); )).ToList();
return Result.Success(result); return Result.Success(result);
} }
} }
@@ -27,15 +27,18 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
private readonly IChatRepository _chatRepository; private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository; private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork; private readonly IChatsUnitOfWork _unitOfWork;
private readonly MediatR.IMediator _mediator;
public SendMessageCommandHandler( public SendMessageCommandHandler(
IChatRepository chatRepository, IChatRepository chatRepository,
IMessageRepository messageRepository, IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork) IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator)
{ {
_chatRepository = chatRepository; _chatRepository = chatRepository;
_messageRepository = messageRepository; _messageRepository = messageRepository;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mediator = mediator;
} }
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken) public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
@@ -54,30 +57,71 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
} }
// 3. Создаем сообщение // 3. Создаем сообщение
var message = Message.Create( Message message;
if (request.Type == "story_reply" || request.Type == "story_reaction")
{
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
message = new StoryMessage(
Guid.NewGuid(),
request.ChatId, request.ChatId,
request.SenderId, request.SenderId,
request.StoryId ?? Guid.Empty,
request.StoryMediaUrl ?? string.Empty,
parsedStoryMediaType,
request.Content, request.Content,
request.Type, request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
else if (request.Attachments != null && request.Attachments.Any())
{
var firstAtt = request.Attachments.First();
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
message = new MediaMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
parsedType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
foreach (var att in request.Attachments)
{
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
((MediaMessage)message).AddMedia(pType, att.Url, att.FileName, att.FileSize);
}
}
else
{
message = new TextMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.Content ?? string.Empty,
request.ReplyToId, request.ReplyToId,
request.Quote, request.Quote,
request.ForwardedFromId, request.ForwardedFromId,
request.StoryId, DateTime.UtcNow,
request.StoryMediaUrl, false);
request.StoryMediaType);
if (request.Attachments != null && request.Attachments.Any())
{
foreach (var att in request.Attachments)
{
message.AddMedia(att.Type, att.Url, att.FileName, att.FileSize);
}
} }
// 4. Сохраняем // 4. Сохраняем
_messageRepository.Add(message); _messageRepository.Add(message);
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
await _mediator.Publish(new MessageSentDomainEvent(
message.Id,
message.ChatId,
message.SenderId,
message.Content),
cancellationToken);
return Result.Success(message.Id); return Result.Success(message.Id);
} }
} }
@@ -350,16 +350,24 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
bool isJoined = fromNameNode == null; bool isJoined = fromNameNode == null;
bool isMediaOnly = string.IsNullOrEmpty(content) && forwardedNode == null && replyToId == null; bool isMediaOnly = string.IsNullOrEmpty(content) && forwardedNode == null && replyToId == null;
bool hasMedia = mediaNodes != null && mediaNodes.Count > 0;
Message? targetMessage = null; Message? targetMessage = null;
if (isJoined && isMediaOnly && lastSavedMessage != null && Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60 && lastSavedMessage.SenderId == senderGuid) if (isJoined && isMediaOnly && lastSavedMessage is MediaMessage && Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60 && lastSavedMessage.SenderId == senderGuid)
{ {
targetMessage = lastSavedMessage; targetMessage = lastSavedMessage;
} }
else else
{ {
string finalContent = content; string finalContent = content;
targetMessage = Message.Import(chatId, senderGuid, finalContent, messageType, createdAt, replyToId, forwardedFromId); if (hasMedia)
{
targetMessage = new MediaMessage(Guid.NewGuid(), chatId, senderGuid, MediaType.File, finalContent, replyToId, forwardedFromId, createdAt, true);
}
else
{
targetMessage = new TextMessage(Guid.NewGuid(), chatId, senderGuid, finalContent, replyToId, null, forwardedFromId, createdAt, true);
}
var idAttr = node.GetAttribute("id"); var idAttr = node.GetAttribute("id");
if (!string.IsNullOrEmpty(idAttr)) if (!string.IsNullOrEmpty(idAttr))
@@ -368,9 +376,9 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
} }
} }
if (mediaNodes != null) if (hasMedia)
{ {
foreach (var mediaNode in mediaNodes) foreach (var mediaNode in mediaNodes!)
{ {
string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src"); string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src");
if (!string.IsNullOrEmpty(href) && !href.StartsWith("http")) if (!string.IsNullOrEmpty(href) && !href.StartsWith("http"))
@@ -391,8 +399,12 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
finalMType = "image"; finalMType = "image";
} }
var parsedType = Enum.TryParse<MediaType>(finalMType, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
if (targetMessage is MediaMessage mm)
{
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType); var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType);
targetMessage.AddMedia(finalMType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length); mm.AddMedia(parsedType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length);
}
} }
} }
} }
@@ -440,3 +452,4 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
return Result.Success(new ExecuteImportResponseDto(true, importedCount, chatId)); return Result.Success(new ExecuteImportResponseDto(true, importedCount, chatId));
} }
} }
@@ -1,9 +1,11 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain; using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using MongoDB.Driver;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence.Mongo;
using Knot.Modules.Chats.Application.Abstractions; using Knot.Modules.Chats.Application.Abstractions;
@@ -24,12 +26,20 @@ public static class DependencyInjection
services.AddDbContext<ChatsDbContext>(options => services.AddDbContext<ChatsDbContext>(options =>
options.UseNpgsql(connectionString)); options.UseNpgsql(connectionString));
// Регистрация Unit of Work и Репозиториев // MongoDB Setup for Messages
MongoDbMapConfigurator.Configure();
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
services.AddSingleton<IMongoClient>(new MongoClient(mongoConnectionString));
services.AddScoped<IMongoDatabase>(sp =>
sp.GetRequiredService<IMongoClient>().GetDatabase("forkmessager_chats"));
// Registration
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>()); services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatRepository, ChatRepository>(); services.AddScoped<IChatRepository, ChatRepository>();
services.AddScoped<IMessageRepository, MessageRepository>(); services.AddScoped<IMessageRepository, MessageRepository>();
// Регистрация MediatR для этого модуля // MediatR
services.AddMediatR(config => services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
@@ -0,0 +1,21 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Запись о том, что конкретный пользователь удалил у себя сообщение.
/// </summary>
public sealed class DeletedMessage : Entity<Guid>
{
public Guid MessageId { get; private set; }
public Guid UserId { get; private set; }
internal DeletedMessage(Guid messageId, Guid userId) : base(Guid.NewGuid())
{
MessageId = messageId;
UserId = userId;
}
private DeletedMessage() : base(Guid.Empty) { }
}
@@ -7,6 +7,7 @@ public interface IMessageRepository
void Add(Message message); void Add(Message message);
Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken); Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken); Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken);
Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken); Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken);
Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken); Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken);
Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken); Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
@@ -14,4 +15,5 @@ public interface IMessageRepository
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken); Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken);
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken); Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken); Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken);
} }
@@ -0,0 +1,31 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Медиа-файл, прикрепленный к сообщению.
/// </summary>
public sealed class Media : Entity<Guid>
{
public Guid MessageId { get; private set; }
public string Type { get; private set; }
public string Url { get; private set; }
public string? Filename { get; private set; }
public long? Size { get; private set; }
internal Media(Guid messageId, string type, string url, string? filename, long? size) : base(Guid.NewGuid())
{
MessageId = messageId;
Type = type;
Url = url;
Filename = filename;
Size = size;
}
private Media() : base(Guid.Empty)
{
Type = string.Empty;
Url = string.Empty;
}
}
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Domain;
public class MediaMessage : Message
{
public override string Type => MediaType.ToString().ToLower();
public override string? Content { get; protected set; } // Map to Caption
public string? Caption { get => Content; private set => Content = value; }
public MediaType MediaType { get; private set; } // image, video, file, voice
private readonly List<Media> _media = new();
public override IReadOnlyCollection<Media> Media => _media.AsReadOnly();
private MediaMessage() : base()
{
MediaType = MediaType.File;
}
public MediaMessage(
Guid id,
Guid chatId,
Guid senderId,
MediaType mediaType,
string? caption,
Guid? replyToId,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
MediaType = mediaType;
Caption = caption;
if (!isImported)
{
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Caption));
}
}
public void AddMedia(MediaType type, string url, string? filename, long? size)
{
_media.Add(new Domain.Media(Id, type.ToString().ToLower(), url, filename, size));
}
public void Edit(string newCaption)
{
Caption = newCaption;
AddState(MessageState.IsEdited);
}
public override void Delete()
{
Caption = null;
base.Delete();
}
}
@@ -0,0 +1,12 @@
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Тип медиа-контента.
/// </summary>
public enum MediaType
{
Image,
Video,
Voice,
File
}
@@ -1,89 +1,77 @@
using System;
using System.Collections.Generic;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain; namespace Knot.Modules.Chats.Domain;
/// <summary> /// <summary>
/// Доменное событие: сообщение отправлено. /// Абстрактная база агрегата Сообщение.
/// </summary> /// </summary>
public sealed record MessageSentDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : IDomainEvent; public abstract class Message : AggregateRoot<Guid>
/// <summary>
/// Сущность сообщения (Агрегат).
/// </summary>
public sealed class Message : AggregateRoot<Guid>
{ {
public Guid ChatId { get; private set; } // ================== Базовые поля ==================
public Guid SenderId { get; private set; } public Guid ChatId { get; protected set; }
public string? Content { get; private set; } public Guid SenderId { get; protected set; }
public string Type { get; private set; } // text, image, video, voice, file public DateTime CreatedAt { get; protected set; }
public Guid? ReplyToId { get; private set; }
public string? Quote { get; private set; }
public bool IsEdited { get; private set; }
public bool IsDeleted { get; private set; }
public Guid? ForwardedFromId { get; private set; }
public Guid? StoryId { get; private set; }
public string? StoryMediaUrl { get; private set; }
public string? StoryMediaType { get; private set; }
public DateTime CreatedAt { get; private set; }
public bool IsImported { get; private set; }
private readonly List<Media> _media = new(); // ================== Опциональные метаданные (общего назначения) ==================
public IReadOnlyCollection<Media> Media => _media.AsReadOnly(); public Guid? ReplyToId { get; protected set; }
public Guid? ForwardedFromId { get; protected set; }
private readonly List<ReadReceipt> _readBy = new(); // ================== Флаги ==================
public MessageState State { get; protected set; }
// ================== Абстрактные / Виртуальные свойства ==================
public abstract string Type { get; }
public abstract string? Content { get; protected set; }
public virtual string? Quote { get; protected set; } = null;
public virtual Guid? StoryId => null;
public virtual string? StoryMediaUrl => null;
public virtual string? StoryMediaType => null;
public virtual IReadOnlyCollection<Media> Media => Array.Empty<Media>();
public bool IsEdited => HasState(MessageState.IsEdited);
public bool IsDeleted => HasState(MessageState.IsDeleted);
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
// ================== Связанные коллекции (общего назначения) ==================
protected readonly List<ReadReceipt> _readBy = new();
public IReadOnlyCollection<ReadReceipt> ReadBy => _readBy.AsReadOnly(); public IReadOnlyCollection<ReadReceipt> ReadBy => _readBy.AsReadOnly();
private readonly List<Guid> _deletedByUsers = new(); protected readonly List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<Guid> DeletedByUsers => _deletedByUsers.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
private Message() : base(Guid.Empty) { Type = "text"; } protected readonly List<Reaction> _reactions = new();
public IReadOnlyCollection<Reaction> Reactions => _reactions.AsReadOnly();
private Message(Guid id, Guid chatId, Guid senderId, string? content, string type, Guid? replyToId, string? quote, Guid? forwardedFromId, Guid? storyId, string? storyMediaUrl, string? storyMediaType, DateTime createdAt, bool isImported) : base(id) // ================== Инфраструктурный конструктор EF ==================
protected Message() : base(Guid.Empty) { }
protected Message(
Guid id,
Guid chatId,
Guid senderId,
Guid? replyToId,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported) : base(id)
{ {
ChatId = chatId; ChatId = chatId;
SenderId = senderId; SenderId = senderId;
Content = content;
Type = type;
ReplyToId = replyToId; ReplyToId = replyToId;
Quote = quote;
ForwardedFromId = forwardedFromId; ForwardedFromId = forwardedFromId;
StoryId = storyId;
StoryMediaUrl = storyMediaUrl;
StoryMediaType = storyMediaType;
CreatedAt = createdAt; CreatedAt = createdAt;
IsImported = isImported;
if (!isImported) // Don't trigger realtime events for historic messages if (isImported) AddState(MessageState.IsImported);
{
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content));
}
} }
public static Message Create(Guid chatId, Guid senderId, string? content, string type, Guid? replyToId = null, string? quote = null, Guid? forwardedFromId = null, Guid? storyId = null, string? storyMediaUrl = null, string? storyMediaType = null) // ================== Управление Состоянием ==================
{ public void AddState(MessageState state) => State |= state;
return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId, storyId, storyMediaUrl, storyMediaType, DateTime.UtcNow, false); public void RemoveState(MessageState state) => State &= ~state;
} public bool HasState(MessageState state) => (State & state) == state;
public static Message Import(
Guid chatId,
Guid senderId,
string? content,
string type,
DateTime createdAt,
Guid? replyToId = null,
Guid? forwardedFromId = null)
{
return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, null, forwardedFromId, null, null, null, createdAt, true);
}
public void AddMedia(string type, string url, string? filename, long? size)
{
_media.Add(new Media(Id, type, url, filename, size));
}
private readonly List<Reaction> _reactions = new();
public IReadOnlyCollection<Reaction> Reactions => _reactions.AsReadOnly();
// ================== Общие операции ==================
public void AddReaction(Guid userId, string emoji) public void AddReaction(Guid userId, string emoji)
{ {
var existing = _reactions.Find(r => r.UserId == userId && r.Emoji == emoji); var existing = _reactions.Find(r => r.UserId == userId && r.Emoji == emoji);
@@ -102,73 +90,17 @@ public sealed class Message : AggregateRoot<Guid>
} }
} }
public void Edit(string newContent) public virtual void Delete()
{ {
Content = newContent; AddState(MessageState.IsDeleted);
IsEdited = true;
}
public void Delete()
{
Content = null;
IsDeleted = true;
// _media.Clear(); // DO NOT CLEAR! Data cleanup needs to know the URLs to delete from S3
_reactions.Clear(); _reactions.Clear();
} }
public void DeleteForUser(Guid userId) public void DeleteForUser(Guid userId)
{ {
if (!_deletedByUsers.Contains(userId)) if (!_deletedFor.Exists(x => x.UserId == userId))
{ {
_deletedByUsers.Add(userId); _deletedFor.Add(new DeletedMessage(Id, userId));
} }
} }
} }
/// <summary>
/// Реакция на сообщение.
/// </summary>
public sealed class Reaction : Entity<Guid>
{
public Guid MessageId { get; private set; }
public Guid UserId { get; private set; }
public string Emoji { get; private set; }
internal Reaction(Guid messageId, Guid userId, string emoji) : base(Guid.NewGuid())
{
MessageId = messageId;
UserId = userId;
Emoji = emoji;
}
private Reaction() : base(Guid.Empty)
{
Emoji = string.Empty;
}
}
/// <summary>
/// Медиа-файл, прикрепленный к сообщению.
/// </summary>
public sealed class Media : Entity<Guid>
{
public Guid MessageId { get; private set; }
public string Type { get; private set; }
public string Url { get; private set; }
public string? Filename { get; private set; }
public long? Size { get; private set; }
internal Media(Guid messageId, string type, string url, string? filename, long? size) : base(Guid.NewGuid())
{
MessageId = messageId;
Type = type;
Url = url;
Filename = filename;
Size = size;
}
private Media() : base(Guid.Empty)
{
Type = string.Empty;
Url = string.Empty;
}
}
@@ -0,0 +1,16 @@
using System;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Флаги состояния сообщения
/// </summary>
[Flags]
public enum MessageFlags
{
None = 0,
IsEdited = 1,
IsDeleted = 2,
IsImported = 4,
IsPinned = 8
}
@@ -0,0 +1,9 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Доменное событие: сообщение отправлено.
/// </summary>
public sealed record MessageSentDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : IDomainEvent;
@@ -0,0 +1,16 @@
using System;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Состояние сообщения
/// </summary>
[Flags]
public enum MessageState
{
None = 0,
IsEdited = 1,
IsDeleted = 2,
IsImported = 4,
IsPinned = 8
}
@@ -0,0 +1,26 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Реакция на сообщение.
/// </summary>
public sealed class Reaction : Entity<Guid>
{
public Guid MessageId { get; private set; }
public Guid UserId { get; private set; }
public string Emoji { get; private set; }
internal Reaction(Guid messageId, Guid userId, string emoji) : base(Guid.NewGuid())
{
MessageId = messageId;
UserId = userId;
Emoji = emoji;
}
private Reaction() : base(Guid.Empty)
{
Emoji = string.Empty;
}
}
@@ -0,0 +1,55 @@
using System;
namespace Knot.Modules.Chats.Domain;
public class StoryMessage : Message
{
public override string Type => "story";
public override string? Content { get; protected set; }
public Guid InternalStoryId { get; private set; }
public override Guid? StoryId => InternalStoryId;
public string InternalStoryMediaUrl { get; private set; }
public override string? StoryMediaUrl => InternalStoryMediaUrl;
public MediaType InternalStoryMediaType { get; private set; }
public override string? StoryMediaType => InternalStoryMediaType.ToString().ToLower();
private StoryMessage() : base()
{
InternalStoryMediaUrl = string.Empty;
InternalStoryMediaType = MediaType.Image;
}
public StoryMessage(
Guid id,
Guid chatId,
Guid senderId,
Guid storyId,
string storyMediaUrl,
MediaType storyMediaType,
string? content,
Guid? replyToId,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
InternalStoryId = storyId;
InternalStoryMediaUrl = storyMediaUrl;
InternalStoryMediaType = storyMediaType;
Content = content;
if (!isImported)
{
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content));
}
}
public override void Delete()
{
Content = null;
base.Delete();
}
}
@@ -0,0 +1,48 @@
using System;
namespace Knot.Modules.Chats.Domain;
public class TextMessage : Message
{
public override string Type => "text";
public override string? Content { get; protected set; }
public override string? Quote { get; protected set; }
private TextMessage() : base()
{
Content = string.Empty;
}
public TextMessage(
Guid id,
Guid chatId,
Guid senderId,
string content,
Guid? replyToId,
string? quote,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
Content = content;
Quote = quote;
if (!isImported)
{
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content));
}
}
public void Edit(string newContent)
{
Content = newContent;
AddState(MessageState.IsEdited);
}
public override void Delete()
{
Content = string.Empty;
base.Delete();
}
}
@@ -77,14 +77,14 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
message.Id, message.Id,
message.ChatId, message.ChatId,
message.SenderId, message.SenderId,
message.Content, Content = message.Content,
message.Type, Type = message.Type,
message.CreatedAt, message.CreatedAt,
message.ForwardedFromId, message.ForwardedFromId,
ForwardedFrom = forwardedFromObj, ForwardedFrom = forwardedFromObj,
message.ReplyToId, message.ReplyToId,
ReplyTo = replyToObj, ReplyTo = replyToObj,
message.Quote, Quote = message.Quote,
Media = message.Media.Select(m => new Media = message.Media.Select(m => new
{ {
m.Id, m.Id,
@@ -95,9 +95,10 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
}).ToList(), }).ToList(),
Sender = senderObj, Sender = senderObj,
ReadBy = new List<object>(), ReadBy = new List<object>(),
message.StoryId, StoryId = message.StoryId,
message.StoryMediaUrl, StoryMediaUrl = message.StoryMediaUrl,
message.StoryMediaType StoryMediaType = message.StoryMediaType
}, cancellationToken); }, cancellationToken);
} }
} }
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Diagnostics;
using Knot.Modules.Chats.Application.Abstractions; using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Domain; using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Chats.Infrastructure.Persistence; namespace Knot.Modules.Chats.Infrastructure.Persistence;
@@ -13,9 +14,9 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence;
public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
{ {
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly Knot.Shared.Kernel.Security.IEncryptionService _encryptionService; private readonly IEncryptionService _encryptionService;
public ChatsDbContext(DbContextOptions<ChatsDbContext> options, IMediator mediator, Knot.Shared.Kernel.Security.IEncryptionService encryptionService) public ChatsDbContext(DbContextOptions<ChatsDbContext> options, IMediator mediator, IEncryptionService encryptionService)
: base(options) : base(options)
{ {
_mediator = mediator; _mediator = mediator;
@@ -23,9 +24,7 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
} }
public DbSet<Chat> Chats => Set<Chat>(); public DbSet<Chat> Chats => Set<Chat>();
public DbSet<Message> Messages => Set<Message>();
public DbSet<ReadReceipt> ReadReceipts => Set<ReadReceipt>();
public DbSet<Reaction> Reactions => Set<Reaction>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
@@ -54,61 +53,6 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field); }).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
}); });
modelBuilder.Entity<Message>(builder =>
{
builder.ToTable("Messages");
builder.HasKey(m => m.Id);
builder.HasIndex(m => m.ChatId);
builder.HasIndex(m => new { m.ChatId, m.CreatedAt });
builder.Property(m => m.Content)
.HasConversion(
v => v == null ? null : _encryptionService.EncryptMessage(v),
v => v == null ? null : _encryptionService.DecryptMessage(v)
);
builder.OwnsMany(m => m.Media, mb =>
{
mb.ToTable("MessageMedia");
mb.WithOwner().HasForeignKey(x => x.MessageId);
}).Navigation(m => m.Media).UsePropertyAccessMode(PropertyAccessMode.Field);
builder.HasMany(m => m.Reactions)
.WithOne()
.HasForeignKey(x => x.MessageId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(m => m.Reactions).UsePropertyAccessMode(PropertyAccessMode.Field);
builder.PrimitiveCollection(m => m.DeletedByUsers)
.HasColumnName("DeletedByUsers")
.UsePropertyAccessMode(PropertyAccessMode.Field);
builder.HasMany(m => m.ReadBy)
.WithOne()
.HasForeignKey(r => r.MessageId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(m => m.ReadBy).UsePropertyAccessMode(PropertyAccessMode.Field);
});
modelBuilder.Entity<Reaction>(builder =>
{
builder.ToTable("MessageReactions");
builder.HasKey(r => r.Id);
builder.HasIndex(r => new { r.MessageId, r.UserId, r.Emoji }).IsUnique();
});
modelBuilder.Entity<ReadReceipt>(builder =>
{
builder.ToTable("ReadReceipts");
builder.HasKey(r => r.Id);
builder.HasIndex(r => new { r.MessageId, r.UserId }).IsUnique();
});
} }
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
@@ -1,162 +1,207 @@
using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
using MongoDB.Driver.Linq;
using Knot.Modules.Chats.Domain; using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using System.Text.RegularExpressions;
using MongoDB.Bson;
namespace Knot.Modules.Chats.Infrastructure.Persistence; namespace Knot.Modules.Chats.Infrastructure.Persistence;
public sealed class MessageRepository : IMessageRepository public sealed class MessageRepository : IMessageRepository
{ {
private readonly IMongoCollection<Message> _messages;
private readonly ChatsDbContext _dbContext; private readonly ChatsDbContext _dbContext;
private readonly MediatR.IMediator _mediator;
public MessageRepository(ChatsDbContext dbContext) public MessageRepository(IMongoDatabase mongoDatabase, ChatsDbContext dbContext, MediatR.IMediator mediator)
{ {
_messages = mongoDatabase.GetCollection<Message>("messages");
_dbContext = dbContext; _dbContext = dbContext;
_mediator = mediator;
} }
public void Add(Message message) public void Add(Message message)
{ {
_dbContext.Messages.Add(message); _messages.InsertOne(message);
// Publish domain events manualy for mongo entities
var events = message.GetDomainEvents().ToList();
message.ClearDomainEvents();
// This runs synchronously or without waiting, better to run async but Add is void
// In this implementation setting, fire and forget or wrap sync
foreach (var domainEvent in events)
{
_mediator.Publish(domainEvent).GetAwaiter().GetResult();
}
} }
public async Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken) public async Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{ {
return await _dbContext.Messages var filter = Builders<Message>.Filter.Eq(m => m.Id, id);
.Include(m => m.Media) return await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
.Include(m => m.ReadBy)
.Include(m => m.Reactions)
.FirstOrDefaultAsync(m => m.Id == id, cancellationToken);
} }
public async Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken) public async Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken)
{ {
return await _dbContext.Messages var filter = Builders<Message>.Filter.Eq(m => m.ChatId, chatId);
.Where(m => m.ChatId == chatId) return await _messages.Find(filter)
.OrderByDescending(m => m.CreatedAt) .SortByDescending(m => m.CreatedAt)
.Skip(offset) .Skip(offset)
.Take(limit) .Limit(limit)
.Include(m => m.Media)
.Include(m => m.ReadBy)
.Include(m => m.Reactions)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public async Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.ChatId, chatId);
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken) public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken)
{ {
var query = _dbContext.Messages var builder = Builders<Message>.Filter;
.Where(m => m.ChatId == chatId); var filter = builder.Eq(m => m.ChatId, chatId);
if (cursor.HasValue) if (cursor.HasValue)
{ {
query = query.Where(m => m.CreatedAt < cursor.Value); filter &= builder.Lt(m => m.CreatedAt, cursor.Value);
} }
return await query return await _messages.Find(filter)
.OrderByDescending(m => m.CreatedAt) .SortByDescending(m => m.CreatedAt)
.Take(limit) .Limit(limit)
.Include(m => m.Media)
.Include(m => m.ReadBy)
.Include(m => m.Reactions)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public async Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken) public async Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken)
{ {
// Not ideal for SQL/Mongo combination but keeping the signature
var validChatIdsQuery = _dbContext.Chats var validChatIdsQuery = _dbContext.Chats
.Where(c => c.Members.Any(m => m.UserId == requestingUserId)) .Where(c => c.Members.Any(m => m.UserId == requestingUserId))
.Select(c => c.Id); .Select(c => c.Id)
.ToList();
var q = _dbContext.Messages.Where(m => validChatIdsQuery.Contains(m.ChatId)); var builder = Builders<Message>.Filter;
var filter = builder.In(m => m.ChatId, validChatIdsQuery);
if (chatId.HasValue) if (chatId.HasValue)
{ {
q = q.Where(m => m.ChatId == chatId.Value); filter &= builder.Eq(m => m.ChatId, chatId.Value);
} }
var textFilter = Builders<Message>.Filter.Regex("Content", new BsonRegularExpression(Regex.Escape(query), "i"));
filter &= textFilter;
return await q.Where(m => m.Content != null && m.Content.Contains(query)) return await _messages.Find(filter)
.OrderByDescending(m => m.CreatedAt) .SortByDescending(m => m.CreatedAt)
.Take(ChatConstants.SearchMessagesLimit) .Limit(ChatConstants.SearchMessagesLimit)
.Include(m => m.Media)
.Include(m => m.ReadBy)
.Include(m => m.Reactions)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public async Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken) public async Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken)
{ {
var existingReceipts = await _dbContext.ReadReceipts var filter = Builders<Message>.Filter.In(m => m.Id, messageIds);
.Where(r => r.UserId == userId && messageIds.Contains(r.MessageId))
.Select(r => r.MessageId)
.ToListAsync(cancellationToken);
var newReceipts = messageIds var messages = await _messages.Find(filter).ToListAsync(cancellationToken);
.Except(existingReceipts)
.Select(id => new ReadReceipt(id, userId));
_dbContext.ReadReceipts.AddRange(newReceipts); var writes = new List<WriteModel<Message>>();
// SaveChangesAsync будет вызван в handlers или через UnitOfWork, но если мы здесь foreach (var msg in messages)
await _dbContext.SaveChangesAsync(cancellationToken); {
if (!msg.ReadBy.Any(r => r.UserId == userId))
{
var receipt = new ReadReceipt(msg.Id, userId);
var pushUpdate = Builders<Message>.Update.Push("ReadBy", receipt);
var updateModel = new UpdateOneModel<Message>(Builders<Message>.Filter.Eq(m => m.Id, msg.Id), pushUpdate);
writes.Add(updateModel);
}
}
if (writes.Any())
{
await _messages.BulkWriteAsync(writes, cancellationToken: cancellationToken);
}
} }
public async Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken) public async Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
{ {
// Проверяем, существует ли сообщение var filter = Builders<Message>.Filter.Eq(m => m.Id, messageId);
var messageExists = await _dbContext.Messages var msg = await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
.AnyAsync(m => m.Id == messageId, cancellationToken); if (msg == null) return false;
if (msg.Reactions.Any(r => r.UserId == userId && r.Emoji == emoji))
if (!messageExists)
{ {
return false; return true;
} }
// Проверяем, есть ли уже такая реакция
var existingReaction = await _dbContext.Reactions
.FirstOrDefaultAsync(r => r.MessageId == messageId && r.UserId == userId && r.Emoji == emoji, cancellationToken);
if (existingReaction != null)
{
return true; // Уже существует
}
// Добавляем новую реакцию напрямую
var reaction = new Reaction(messageId, userId, emoji); var reaction = new Reaction(messageId, userId, emoji);
_dbContext.Reactions.Add(reaction); var update = Builders<Message>.Update.Push("Reactions", reaction);
await _messages.UpdateOneAsync(filter, update, cancellationToken: cancellationToken);
return true; return true;
} }
public async Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken) public async Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
{ {
// Ищем реакцию var filter = Builders<Message>.Filter.Eq(m => m.Id, messageId);
var reaction = await _dbContext.Reactions var msg = await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
.FirstOrDefaultAsync(r => r.MessageId == messageId && r.UserId == userId && r.Emoji == emoji, cancellationToken); if (msg == null) return false;
var reaction = msg.Reactions.FirstOrDefault(r => r.UserId == userId && r.Emoji == emoji);
if (reaction == null) return false;
if (reaction == null) var update = Builders<Message>.Update.PullFilter("Reactions",
{ Builders<BsonDocument>.Filter.And(
return false; Builders<BsonDocument>.Filter.Eq("UserId", userId),
} Builders<BsonDocument>.Filter.Eq("Emoji", emoji)
));
_dbContext.Reactions.Remove(reaction);
await _messages.UpdateOneAsync(filter, update, cancellationToken: cancellationToken);
return true; return true;
} }
public async Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken) public async Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken)
{ {
return await _dbContext.Messages var filter = Builders<Message>.Filter.And(
.Where(m => m.ChatId == chatId && m.StoryId == storyId) Builders<Message>.Filter.Eq(m => m.ChatId, chatId),
.OrderByDescending(m => m.CreatedAt) Builders<Message>.Filter.Eq("_t", "StoryMessage"),
Builders<Message>.Filter.Eq("StoryId", storyId)
);
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
} }
public async Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken) public async Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken)
{ {
return await _dbContext.Messages var notReadFilter = Builders<Message>.Filter.Not(
.Where(m => m.ChatId == chatId && m.SenderId != userId && !m.ReadBy.Any(r => r.UserId == userId)) Builders<Message>.Filter.ElemMatch("ReadBy",
.CountAsync(cancellationToken); Builders<BsonDocument>.Filter.Eq("UserId", userId))
);
var finalFilter = Builders<Message>.Filter.And(
Builders<Message>.Filter.Eq(m => m.ChatId, chatId),
Builders<Message>.Filter.Ne(m => m.SenderId, userId),
notReadFilter
);
return (int)await _messages.CountDocumentsAsync(finalFilter, cancellationToken: cancellationToken);
}
public async Task UpdateAsync(Message message, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.Id, message.Id);
await _messages.ReplaceOneAsync(filter, message, new ReplaceOptions { IsUpsert = true }, cancellationToken);
// Publish domain events
var events = message.GetDomainEvents().ToList();
message.ClearDomainEvents();
foreach (var domainEvent in events)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
} }
} }
@@ -1,207 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Chats.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260311215929_UpdateChatModel")]
partial class UpdateChatModel
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId")
.IsUnique();
b.ToTable("ReadReceipts", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("Id");
b1.HasIndex("MessageId");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("ReadBy")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Navigation("ReadBy");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,52 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class UpdateChatModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ReadReceipts",
schema: "chats",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MessageId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ReadAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReadReceipts", x => x.Id);
table.ForeignKey(
name: "FK_ReadReceipts_Messages_MessageId",
column: x => x.MessageId,
principalSchema: "chats",
principalTable: "Messages",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ReadReceipts_MessageId_UserId",
schema: "chats",
table: "ReadReceipts",
columns: new[] { "MessageId", "UserId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ReadReceipts",
schema: "chats");
}
}
}
@@ -1,210 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Chats.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260312094130_AddForwardedFromToMessages")]
partial class AddForwardedFromToMessages
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId")
.IsUnique();
b.ToTable("ReadReceipts", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("Id");
b1.HasIndex("MessageId");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("ReadBy")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Navigation("ReadBy");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,31 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddForwardedFromToMessages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ForwardedFromId",
schema: "chats",
table: "Messages",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ForwardedFromId",
schema: "chats",
table: "Messages");
}
}
}
@@ -1,239 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Chats.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260312100324_AddReactionsToMessages")]
partial class AddReactionsToMessages
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId")
.IsUnique();
b.ToTable("ReadReceipts", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("Id");
b1.HasIndex("MessageId");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.OwnsMany("Knot.Modules.Chats.Domain.Reaction", "Reactions", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("MessageId", "UserId", "Emoji")
.IsUnique();
b1.ToTable("MessageReactions", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
b.Navigation("Reactions");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("ReadBy")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Navigation("ReadBy");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,52 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddReactionsToMessages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MessageReactions",
schema: "chats",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MessageId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Emoji = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MessageReactions", x => x.Id);
table.ForeignKey(
name: "FK_MessageReactions_Messages_MessageId",
column: x => x.MessageId,
principalSchema: "chats",
principalTable: "Messages",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_MessageReactions_MessageId_UserId_Emoji",
schema: "chats",
table: "MessageReactions",
columns: new[] { "MessageId", "UserId", "Emoji" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MessageReactions",
schema: "chats");
}
}
}
@@ -1,244 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Chats.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260312174910_UpdateChatsSchema")]
partial class UpdateChatsSchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId")
.IsUnique();
b.ToTable("ReadReceipts", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("Id");
b1.HasIndex("MessageId");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.OwnsMany("Knot.Modules.Chats.Domain.Reaction", "Reactions", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("MessageId", "UserId", "Emoji")
.IsUnique();
b1.ToTable("MessageReactions", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
b.Navigation("Reactions");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("ReadBy")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Navigation("ReadBy");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,32 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class UpdateChatsSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid[]>(
name: "DeletedByUsers",
schema: "chats",
table: "Messages",
type: "uuid[]",
nullable: false,
defaultValue: new Guid[0]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeletedByUsers",
schema: "chats",
table: "Messages");
}
}
}
@@ -1,251 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Chats.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260312204842_SupportPinningAndMuting")]
partial class SupportPinningAndMuting
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId", "Emoji")
.IsUnique();
b.ToTable("MessageReactions", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId")
.IsUnique();
b.ToTable("ReadReceipts", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("MessageId", "Id");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("Reactions")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("ReadBy")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Navigation("Reactions");
b.Navigation("ReadBy");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,64 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class SupportPinningAndMuting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_MessageMedia",
schema: "chats",
table: "MessageMedia");
migrationBuilder.DropIndex(
name: "IX_MessageMedia_MessageId",
schema: "chats",
table: "MessageMedia");
migrationBuilder.AddColumn<bool>(
name: "IsPinned",
schema: "chats",
table: "ChatMembers",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddPrimaryKey(
name: "PK_MessageMedia",
schema: "chats",
table: "MessageMedia",
columns: new[] { "MessageId", "Id" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_MessageMedia",
schema: "chats",
table: "MessageMedia");
migrationBuilder.DropColumn(
name: "IsPinned",
schema: "chats",
table: "ChatMembers");
migrationBuilder.AddPrimaryKey(
name: "PK_MessageMedia",
schema: "chats",
table: "MessageMedia",
column: "Id");
migrationBuilder.CreateIndex(
name: "IX_MessageMedia_MessageId",
schema: "chats",
table: "MessageMedia",
column: "MessageId");
}
}
}
@@ -1,254 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Chats.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260313183634_AddStoryIdToMessages")]
partial class AddStoryIdToMessages
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId", "Emoji")
.IsUnique();
b.ToTable("MessageReactions", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId")
.IsUnique();
b.ToTable("ReadReceipts", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("MessageId", "Id");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("Reactions")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("ReadBy")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Navigation("Reactions");
b.Navigation("ReadBy");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,31 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStoryIdToMessages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "StoryId",
schema: "chats",
table: "Messages",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "StoryId",
schema: "chats",
table: "Messages");
}
}
}
@@ -1,260 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Chats.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260313201532_AddStoryMediaInfoToMessages")]
partial class AddStoryMediaInfoToMessages
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("StoryMediaType")
.HasColumnType("text");
b.Property<string>("StoryMediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId", "Emoji")
.IsUnique();
b.ToTable("MessageReactions", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId")
.IsUnique();
b.ToTable("ReadReceipts", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("MessageId", "Id");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("Reactions")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("ReadBy")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Navigation("Reactions");
b.Navigation("ReadBy");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,42 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStoryMediaInfoToMessages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "StoryMediaType",
schema: "chats",
table: "Messages",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "StoryMediaUrl",
schema: "chats",
table: "Messages",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "StoryMediaType",
schema: "chats",
table: "Messages");
migrationBuilder.DropColumn(
name: "StoryMediaUrl",
schema: "chats",
table: "Messages");
}
}
}
@@ -1,263 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Chats.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260313204219_AddChatDescription")]
partial class AddChatDescription
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("StoryMediaType")
.HasColumnType("text");
b.Property<string>("StoryMediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId", "Emoji")
.IsUnique();
b.ToTable("MessageReactions", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId")
.IsUnique();
b.ToTable("ReadReceipts", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("MessageId", "Id");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("Reactions")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("ReadBy")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Navigation("Reactions");
b.Navigation("ReadBy");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,30 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddChatDescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Description",
schema: "chats",
table: "Chats",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Description",
schema: "chats",
table: "Chats");
}
}
}
@@ -1,266 +0,0 @@
// <auto-generated />
using System;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260316142303_AddIsImportedToMessage")]
partial class AddIsImportedToMessage
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<bool>("IsImported")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("StoryMediaType")
.HasColumnType("text");
b.Property<string>("StoryMediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId", "Emoji")
.IsUnique();
b.ToTable("MessageReactions", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId")
.IsUnique();
b.ToTable("ReadReceipts", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("MessageId", "Id");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("Reactions")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("ReadBy")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Navigation("Reactions");
b.Navigation("ReadBy");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,31 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddIsImportedToMessage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsImported",
schema: "chats",
table: "Messages",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsImported",
schema: "chats",
table: "Messages");
}
}
}
@@ -1,266 +0,0 @@
// <auto-generated />
using System;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260316143533_RemoveIsImportedDefault")]
partial class RemoveIsImportedDefault
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<bool>("IsImported")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("StoryMediaType")
.HasColumnType("text");
b.Property<string>("StoryMediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId", "Emoji")
.IsUnique();
b.ToTable("MessageReactions", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId")
.IsUnique();
b.ToTable("ReadReceipts", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("MessageId", "Id");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("Reactions")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("ReadBy")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Navigation("Reactions");
b.Navigation("ReadBy");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RemoveIsImportedDefault : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -1,263 +0,0 @@
// <auto-generated />
using System;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ChatsDbContext))]
partial class ChatsDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<bool>("IsImported")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("StoryMediaType")
.HasColumnType("text");
b.Property<string>("StoryMediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId", "Emoji")
.IsUnique();
b.ToTable("MessageReactions", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId", "UserId")
.IsUnique();
b.ToTable("ReadReceipts", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("MessageId", "Id");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("Reactions")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b =>
{
b.HasOne("Knot.Modules.Chats.Domain.Message", null)
.WithMany("ReadBy")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Navigation("Reactions");
b.Navigation("ReadBy");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,49 @@
using System;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Chats.Infrastructure.Persistence.Mongo;
public class EncryptedStringSerializer : SerializerBase<string>
{
public static IEncryptionService? EncryptionService { get; set; }
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, string value)
{
if (string.IsNullOrEmpty(value) || EncryptionService == null)
{
context.Writer.WriteString(value ?? string.Empty);
return;
}
try
{
var encrypted = EncryptionService.EncryptMessage(value);
context.Writer.WriteString(encrypted);
}
catch
{
context.Writer.WriteString(value);
}
}
public override string Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
{
var value = context.Reader.ReadString();
if (string.IsNullOrEmpty(value) || EncryptionService == null)
{
return value;
}
try
{
return EncryptionService.DecryptMessage(value);
}
catch
{
// Fallback for already unencrypted, or failed to decrypt
return value;
}
}
}
@@ -0,0 +1,73 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Infrastructure.Persistence.Mongo;
public static class MongoDbMapConfigurator
{
private static bool _initialized;
public static void Configure()
{
if (_initialized) return;
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
BsonSerializer.RegisterSerializer(new EnumSerializer<MessageState>(BsonType.String));
BsonSerializer.RegisterSerializer(new EnumSerializer<MediaType>(BsonType.String));
BsonClassMap.RegisterClassMap<Entity<Guid>>(cm =>
{
cm.AutoMap();
cm.MapIdProperty(e => e.Id);
});
BsonClassMap.RegisterClassMap<Message>(cm =>
{
cm.AutoMap();
cm.MapField("_deletedFor").SetElementName("DeletedFor");
cm.MapField("_readBy").SetElementName("ReadBy");
cm.MapField("_reactions").SetElementName("Reactions");
cm.SetIsRootClass(true);
});
BsonClassMap.RegisterClassMap<TextMessage>(cm =>
{
cm.AutoMap();
cm.SetDiscriminator("TextMessage");
cm.MapProperty(c => c.Content).SetSerializer(new EncryptedStringSerializer());
});
BsonClassMap.RegisterClassMap<MediaMessage>(cm =>
{
cm.AutoMap();
cm.MapField("_media").SetElementName("Media");
cm.SetDiscriminator("MediaMessage");
cm.MapProperty(c => c.Caption).SetSerializer(new EncryptedStringSerializer());
});
BsonClassMap.RegisterClassMap<StoryMessage>(cm =>
{
cm.AutoMap();
cm.SetDiscriminator("StoryMessage");
cm.MapProperty(c => c.Content).SetSerializer(new EncryptedStringSerializer());
cm.MapProperty(c => c.InternalStoryMediaUrl).SetSerializer(new EncryptedStringSerializer());
});
BsonClassMap.RegisterClassMap<DeletedMessage>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<ReadReceipt>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<Reaction>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<Media>(cm =>
{
cm.AutoMap();
cm.MapProperty(c => c.Url).SetSerializer(new EncryptedStringSerializer());
cm.MapProperty(c => c.Filename).SetSerializer(new EncryptedStringSerializer());
});
_initialized = true;
}
}
@@ -5,6 +5,9 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Knot.Modules.Chats.Application.Messages.Send; using Knot.Modules.Chats.Application.Messages.Send;
using Knot.Modules.Chats.Application.Messages.Read;
using Knot.Modules.Chats.Application.Messages.Delete;
using Knot.Modules.Chats.Application.Messages.React;
using Knot.Modules.Chats.Domain; using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
@@ -124,7 +127,7 @@ public sealed class ChatHub : Hub
if (parsedIds.Any()) if (parsedIds.Any())
{ {
var command = new Knot.Modules.Chats.Application.Messages.Read.ReadMessagesCommand( var command = new ReadMessagesCommand(
request.ChatId, _userContext.UserId, parsedIds); request.ChatId, _userContext.UserId, parsedIds);
await _sender.Send(command); await _sender.Send(command);
} }
@@ -148,7 +151,7 @@ public sealed class ChatHub : Hub
if (parsedIds.Any()) if (parsedIds.Any())
{ {
var command = new Knot.Modules.Chats.Application.Messages.Delete.DeleteMessagesCommand( var command = new DeleteMessagesCommand(
request.ChatId, _userContext.UserId, parsedIds, request.DeleteForAll); request.ChatId, _userContext.UserId, parsedIds, request.DeleteForAll);
await _sender.Send(command); await _sender.Send(command);
} }
@@ -188,7 +191,7 @@ public sealed class ChatHub : Hub
request.MessageId, request.ChatId, request.Emoji, _userContext.UserId); request.MessageId, request.ChatId, request.Emoji, _userContext.UserId);
var command = new Knot.Modules.Chats.Application.Messages.React.AddReactionCommand( var command = new AddReactionCommand(
request.MessageId, _userContext.UserId, request.Emoji, request.ChatId); request.MessageId, _userContext.UserId, request.Emoji, request.ChatId);
var result = await _sender.Send(command); var result = await _sender.Send(command);
if (result.IsFailure) if (result.IsFailure)
@@ -209,7 +212,7 @@ public sealed class ChatHub : Hub
request.MessageId, request.ChatId, request.Emoji, _userContext.UserId); request.MessageId, request.ChatId, request.Emoji, _userContext.UserId);
var command = new Knot.Modules.Chats.Application.Messages.React.RemoveReactionCommand( var command = new RemoveReactionCommand(
request.MessageId, _userContext.UserId, request.Emoji, request.ChatId); request.MessageId, _userContext.UserId, request.Emoji, request.ChatId);
var result = await _sender.Send(command); var result = await _sender.Send(command);
if (result.IsFailure) if (result.IsFailure)
@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.7.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup> </ItemGroup>
@@ -1,18 +1,18 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Chats.Infrastructure.Persistence;
#nullable disable #nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations namespace Knot.Modules.Chats.Migrations
{ {
[DbContext(typeof(ChatsDbContext))] [DbContext(typeof(ChatsDbContext))]
[Migration("20260311180825_InitialChats")] [Migration("20260319124845_InitialChats")]
partial class InitialChats partial class InitialChats
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -38,6 +38,9 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("text"); .HasColumnType("text");
@@ -50,45 +53,6 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
b.ToTable("Chats", "chats"); b.ToTable("Chats", "chats");
}); });
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b => modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{ {
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 => b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
@@ -103,6 +67,9 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
b1.Property<bool>("IsMuted") b1.Property<bool>("IsMuted")
.HasColumnType("boolean"); .HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt") b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -126,44 +93,6 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
b.Navigation("Members"); b.Navigation("Members");
}); });
modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("Id");
b1.HasIndex("MessageId");
b1.ToTable("MessageMedia", "chats");
b1.WithOwner()
.HasForeignKey("MessageId");
});
b.Navigation("Media");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
@@ -1,9 +1,9 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations namespace Knot.Modules.Chats.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class InitialChats : Migration public partial class InitialChats : Migration
@@ -22,6 +22,7 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "text", nullable: false), Type = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: true), Name = table.Column<string>(type: "text", nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
Avatar = table.Column<string>(type: "text", nullable: true), Avatar = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
}, },
@@ -30,27 +31,6 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
table.PrimaryKey("PK_Chats", x => x.Id); table.PrimaryKey("PK_Chats", x => x.Id);
}); });
migrationBuilder.CreateTable(
name: "Messages",
schema: "chats",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChatId = table.Column<Guid>(type: "uuid", nullable: false),
SenderId = table.Column<Guid>(type: "uuid", nullable: false),
Content = table.Column<string>(type: "text", nullable: true),
Type = table.Column<string>(type: "text", nullable: false),
ReplyToId = table.Column<Guid>(type: "uuid", nullable: true),
Quote = table.Column<string>(type: "text", nullable: true),
IsEdited = table.Column<bool>(type: "boolean", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Messages", x => x.Id);
});
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ChatMembers", name: "ChatMembers",
schema: "chats", schema: "chats",
@@ -61,6 +41,7 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
UserId = table.Column<Guid>(type: "uuid", nullable: false), UserId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "text", nullable: false), Role = table.Column<string>(type: "text", nullable: false),
JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsPinned = table.Column<bool>(type: "boolean", nullable: false),
IsMuted = table.Column<bool>(type: "boolean", nullable: false) IsMuted = table.Column<bool>(type: "boolean", nullable: false)
}, },
constraints: table => constraints: table =>
@@ -75,42 +56,12 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
migrationBuilder.CreateTable(
name: "MessageMedia",
schema: "chats",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MessageId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Url = table.Column<string>(type: "text", nullable: false),
Filename = table.Column<string>(type: "text", nullable: true),
Size = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MessageMedia", x => x.Id);
table.ForeignKey(
name: "FK_MessageMedia_Messages_MessageId",
column: x => x.MessageId,
principalSchema: "chats",
principalTable: "Messages",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ChatMembers_ChatId_UserId", name: "IX_ChatMembers_ChatId_UserId",
schema: "chats", schema: "chats",
table: "ChatMembers", table: "ChatMembers",
columns: new[] { "ChatId", "UserId" }, columns: new[] { "ChatId", "UserId" },
unique: true); unique: true);
migrationBuilder.CreateIndex(
name: "IX_MessageMedia_MessageId",
schema: "chats",
table: "MessageMedia",
column: "MessageId");
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -120,17 +71,9 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
name: "ChatMembers", name: "ChatMembers",
schema: "chats"); schema: "chats");
migrationBuilder.DropTable(
name: "MessageMedia",
schema: "chats");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "Chats", name: "Chats",
schema: "chats"); schema: "chats");
migrationBuilder.DropTable(
name: "Messages",
schema: "chats");
} }
} }
} }
@@ -0,0 +1,96 @@
// <auto-generated />
using System;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Chats.Migrations
{
[DbContext(typeof(ChatsDbContext))]
partial class ChatsDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FluentAssertions; using FluentAssertions;
using MediatR;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
@@ -18,6 +19,7 @@ public class SendMessageCommandHandlerTests
private readonly IChatRepository _chatRepository; private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository; private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork; private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMediator _mediator;
private readonly SendMessageCommandHandler _handler; private readonly SendMessageCommandHandler _handler;
public SendMessageCommandHandlerTests() public SendMessageCommandHandlerTests()
@@ -25,8 +27,9 @@ public class SendMessageCommandHandlerTests
_chatRepository = Substitute.For<IChatRepository>(); _chatRepository = Substitute.For<IChatRepository>();
_messageRepository = Substitute.For<IMessageRepository>(); _messageRepository = Substitute.For<IMessageRepository>();
_unitOfWork = Substitute.For<IChatsUnitOfWork>(); _unitOfWork = Substitute.For<IChatsUnitOfWork>();
_mediator = Substitute.For<IMediator>();
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork); _handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator);
} }
[Fact] [Fact]
+9
View File
@@ -21,6 +21,7 @@ services:
depends_on: depends_on:
- db - db
- minio - minio
- mongo
ports: ports:
- "5059:8080" - "5059:8080"
@@ -36,6 +37,14 @@ services:
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
volumes: volumes:
- minio_data:/data - minio_data:/data
mongo:
image: mongo:6-jammy
container_name: knot-mongo
restart: always
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
web: web:
build: build: