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

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

View File

@@ -8,6 +8,8 @@ using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
namespace Host.Application.Admin.Commands;
@@ -17,33 +19,36 @@ public record CleanRunCommand(IFileStorageService FileStorage, IdentityDbContext
internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanRunCommandHandler(ChatsDbContext chatsDbContext)
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("Messages");
}
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
{
var orphanMessages = await _chatsDbContext.Messages
.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 activeChatIds = allChats.Select(c => c.Id).ToHashSet();
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 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 validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
@@ -85,8 +90,9 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
if (orphanMessages.Any())
{
_chatsDbContext.Messages.RemoveRange(orphanMessages);
await _chatsDbContext.SaveChangesAsync(cancellationToken);
var orphanIds = orphanMessages.Select(m => m.Id).ToList();
var filter = Builders<Message>.Filter.In(m => m.Id, orphanIds);
await _messages.DeleteManyAsync(filter, cancellationToken);
}
return Result.Success(new MessageResponse("Cleanup completed successfully"));

View File

@@ -8,6 +8,8 @@ using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
using Host.Models;
@@ -18,35 +20,38 @@ public record CleanDryRunQuery(IFileStorageService FileStorage, IdentityDbContex
internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext)
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("Messages");
}
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
{
var orphanedMessages = await _chatsDbContext.Messages
.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 activeChatIds = allChats.Select(c => c.Id).ToHashSet();
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 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 validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)

View File

@@ -6,8 +6,8 @@ using MediatR;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
namespace Host.Application.Admin.Queries;
@@ -16,12 +16,12 @@ public record GetUserDetailsQuery(Guid UserId) : IQuery<AdminUserDetailsDto>;
internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDetailsDto>
{
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;
_chatsDbContext = chatsDbContext;
_messages = mongoDatabase.GetCollection<Message>("Messages");
}
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);
}
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
.AsNoTracking()
.Where(m => m.SenderId == request.UserId)
.SelectMany(m => m.Media)
.ToListAsync(cancellationToken);
var messagesCount = userMessages.Count;
var allUserMedia = userMessages.OfType<MediaMessage>().SelectMany(m => m.Media).ToList();
var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video");
var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio");
var storageUsed = allUserMedia.Sum(m => m.Size ?? 0);
var userContents = await _chatsDbContext.Messages
.AsNoTracking()
.Where(m => m.SenderId == request.UserId)
.Select(m => m.Content)
.ToListAsync(cancellationToken);
var userContents = userMessages.OfType<TextMessage>().Select(m => m.Content)
.Concat(userMessages.OfType<MediaMessage>().Where(m => m.Caption != null).Select(m => m.Caption))
.ToList();
var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http"));

View File

@@ -27,6 +27,7 @@ var builder = WebApplication.CreateBuilder(args);
var envMappings = new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"],
["ConnectionStrings:MongoConnection"] = builder.Configuration["MONGO_CONNECTION"],
["Jwt:Secret"] = builder.Configuration["JWT_SECRET"],
["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"],
["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>();
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
var settingsService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Configuration.ISettingsService>();
if (settingsService is Knot.Shared.Infrastructure.Configuration.SettingsService concreteSettings)

View File

@@ -40,33 +40,32 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
}
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 mFirst = chatMessages.FirstOrDefault();
if (mFirst != null)
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
if (latestMessage != null)
{
userIdsToFetch.Add(mFirst.SenderId);
foreach (var r in mFirst.Reactions)
userIdsToFetch.Add(latestMessage.SenderId);
foreach (var reaction in latestMessage.Reactions)
{
userIdsToFetch.Add(r.UserId);
userIdsToFetch.Add(reaction.UserId);
}
}
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
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(
m.Id,
m.UserId,
m.Role,
m.IsPinned,
member.Id,
member.UserId,
member.Role,
member.IsPinned,
user != null ? new ChatUserDto(
user.Id,
user.Username,
@@ -79,48 +78,47 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
}
var messagesList = new List<ChatMessageDto>();
if (chatMessages.Any())
if (latestMessage != null)
{
var m = mFirst;
usersInfo.TryGetValue(m.SenderId, out var senderObj);
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
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(
r.Id,
r.Emoji,
r.UserId,
rUser != null
? new MessageSenderDto(rUser.Id, rUser.Username, rUser.DisplayName, rUser.Avatar)
: new MessageSenderDto(r.UserId, "unknown", "Unknown", null)
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
messagesList.Add(new ChatMessageDto(
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
m.Quote,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
m.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
latestMessage.Id,
latestMessage.ChatId,
latestMessage.SenderId,
latestMessage.Content,
latestMessage.Type,
latestMessage.ReplyToId,
latestMessage.Quote,
latestMessage.StoryId,
latestMessage.StoryMediaUrl,
latestMessage.StoryMediaType,
latestMessage.IsEdited,
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.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(m.SenderId, "unknown", "Unknown", null),
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
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);
}
}

View File

@@ -33,36 +33,38 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
var dtos = new List<ChatDto>();
bool hasFavorites = false;
foreach (var c in userChats)
foreach (var chat in userChats)
{
var userIdsToFetch = new HashSet<Guid>();
foreach (var m in c.Members)
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
if (latestMessage == null)
{
userIdsToFetch.Add(m.UserId);
continue;
}
var chatMessages = await _messageRepository.GetChatMessagesAsync(c.Id, 1, 0, cancellationToken);
var mFirst = chatMessages.FirstOrDefault();
if (mFirst != null)
var userIdsToFetch = new HashSet<Guid>();
foreach (var member in chat.Members)
{
userIdsToFetch.Add(mFirst.SenderId);
foreach (var r in mFirst.Reactions)
{
userIdsToFetch.Add(r.UserId);
}
userIdsToFetch.Add(member.UserId);
}
userIdsToFetch.Add(latestMessage.SenderId);
foreach (var r in latestMessage.Reactions)
{
userIdsToFetch.Add(r.UserId);
}
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
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(
m.Id,
m.UserId,
m.Role,
m.IsPinned,
member.Id,
member.UserId,
member.Role,
member.IsPinned,
user != null ? new ChatUserDto(
user.Id,
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 reactionsWithUser = new List<ReactionDto>();
foreach (var reaction in latestMessage.Reactions)
{
var m = mFirst;
usersInfo.TryGetValue(m.SenderId, out var senderObj);
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
var reactionsWithUser = new List<ReactionDto>();
foreach (var r in m.Reactions)
{
usersInfo.TryGetValue(r.UserId, out var rUser);
reactionsWithUser.Add(new ReactionDto(
r.Id,
r.Emoji,
r.UserId,
rUser != null
? new MessageSenderDto(rUser.Id, rUser.Username, rUser.DisplayName, rUser.Avatar)
: new MessageSenderDto(r.UserId, "unknown", "Unknown", null)
));
}
messagesList.Add(new ChatMessageDto(
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
m.Quote,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
m.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
var messagesList = new List<ChatMessageDto>
{
new ChatMessageDto(
latestMessage.Id,
latestMessage.ChatId,
latestMessage.SenderId,
latestMessage.Content,
latestMessage.Type,
latestMessage.ReplyToId,
latestMessage.Quote,
latestMessage.StoryId,
latestMessage.StoryMediaUrl,
latestMessage.StoryMediaType,
latestMessage.IsEdited,
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.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(m.SenderId, "unknown", "Unknown", null),
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
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(
c.Id,
c.Type.ToString().ToLowerInvariant(),
c.Type == ChatType.Favorites ? "Избранное" : (c.Type == ChatType.Personal ? null : c.Name),
c.Description,
c.Avatar,
c.CreatedAt,
chat.Id,
chat.Type.ToString().ToLowerInvariant(),
chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
chat.Description,
chat.Avatar,
chat.CreatedAt,
members,
messagesList,
unreadCount
@@ -146,3 +145,4 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
return Result.Success(sorted);
}
}

View File

@@ -50,9 +50,9 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
{
message.DeleteForUser(request.UserId);
}
}
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _messageRepository.UpdateAsync(message, cancellationToken);
}
if (request.DeleteForAll)
{

View File

@@ -47,46 +47,39 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
var userIdsToFetch = new HashSet<Guid>();
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;
}
userIdsToFetch.Add(m.SenderId);
if (m.ForwardedFromId.HasValue)
var replyMsg = await _messageRepository.GetByIdAsync(m.ReplyToId.Value, cancellationToken);
if (replyMsg == null)
{
userIdsToFetch.Add(m.ForwardedFromId.Value);
continue;
}
foreach (var r in m.Reactions)
{
userIdsToFetch.Add(r.UserId);
}
if (m.ReplyToId.HasValue)
{
var replyMsg = await _messageRepository.GetByIdAsync(m.ReplyToId.Value, cancellationToken);
if (replyMsg != null)
{
replyMessages[replyMsg.Id] = replyMsg;
userIdsToFetch.Add(replyMsg.SenderId);
}
}
replyMessages[replyMsg.Id] = replyMsg;
userIdsToFetch.Add(replyMsg.SenderId);
}
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;
}
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)
? new MessageSenderDto(rs.Id, rs.Username, rs.DisplayName, rs.Avatar)
@@ -94,48 +87,48 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
replyToObj = new ReplyToMessageDto(
replyMsg.Id,
replyMsg.Content,
replyMsg.IsDeleted,
replyMsg.Media.Select(rm => new MediaDto(rm.Id, rm.Type, rm.Url, rm.Filename, rm.Size)).ToList(),
replyMsg.Content,
replyMsg.IsDeleted,
replyMsg.Media.Select(rm => new MediaDto(rm.Id, rm.Type, rm.Url, rm.Filename, rm.Size)).ToList(),
senderObj
);
}
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)
? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar)
: new MessageSenderDto(r.UserId, "unknown", "Unknown", null);
var userObj = senders.TryGetValue(reaction.UserId, out var reactionUser)
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null);
reactionsWithUser.Add(new MessageReactionDto(
r.Id,
r.Emoji,
r.UserId,
reaction.Id,
reaction.Emoji,
reaction.UserId,
userObj
));
}
result.Add(new MessageDetailDto(
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
message.Id,
message.ChatId,
message.SenderId,
message.Content,
message.Type,
message.ReplyToId,
replyToObj,
m.Quote,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
m.ForwardedFromId,
m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new MessageSenderDto(fwd.Id, fwd.Username, fwd.DisplayName, fwd.Avatar) : null,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.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,
m.ReadBy.Select(r => new ReadByDto(r.UserId)).ToList(),
message.Quote,
message.IsEdited,
message.IsDeleted,
message.CreatedAt,
message.ForwardedFromId,
message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser) ? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar) : null,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : null,
message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList(),
reactionsWithUser
));
}
@@ -143,3 +136,4 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
return Result.Success(result);
}
}

View File

@@ -31,36 +31,38 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
public async Task<Result<List<SharedMediaDto>>> Handle(GetSharedMediaQuery request, CancellationToken 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);
}
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 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);
foreach (var m in messages)
foreach (var message in messages)
{
if (filterType == "links")
{
var messageContent = message.Content;
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 mediaLinks = (m.Media ?? Enumerable.Empty<Media>()).Where(media => media.Type?.ToLower() == "link").Select(media => media.Url).ToList();
var contentLinks = !string.IsNullOrEmpty(messageContent) ? linkRegex.Matches(messageContent).Select(match => match.Value).ToList() : new List<string>();
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();
if (allLinks.Any())
{
senders.TryGetValue(m.SenderId, out var sender);
senders.TryGetValue(message.SenderId, out var sender);
result.Add(new SharedMediaDto(
m.Id,
m.Content,
m.CreatedAt,
message.Id,
messageContent,
message.CreatedAt,
allLinks,
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null,
null, null, null, null, null, null, null, null
@@ -69,21 +71,17 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
continue;
}
if (m.Media == null || !m.Media.Any())
var messageMedia = message.Media;
if (messageMedia == null || !messageMedia.Any())
{
continue;
}
var filteredMedia = m.Media.Where(media =>
var filteredMedia = messageMedia.Where(media =>
{
var mediaType = media.Type?.ToLower() ?? "file";
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")
{
return isGif;
@@ -99,20 +97,20 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
if (filteredMedia.Any())
{
senders.TryGetValue(m.SenderId, out var sender);
senders.TryGetValue(message.SenderId, out var sender);
result.Add(new SharedMediaDto(
m.Id,
m.Content,
m.CreatedAt,
message.Id,
message.Content,
message.CreatedAt,
null,
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null,
m.ReplyToId,
m.Quote,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.IsEdited,
m.Type,
message.ReplyToId,
message.Quote,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,
message.IsEdited,
message.Type,
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());
}
}

View File

@@ -26,35 +26,36 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
public async Task<Result<List<SearchMessageDto>>> Handle(SearchMessagesQuery request, CancellationToken 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();
userIds.AddRange(messages.Where(m => m.ForwardedFromId.HasValue).Select(m => m.ForwardedFromId!.Value));
var userIds = messages.Select(message => message.SenderId).ToList();
userIds.AddRange(messages.Where(message => message.ForwardedFromId.HasValue).Select(message => message.ForwardedFromId!.Value));
var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken);
var result = messages.Select(m => new SearchMessageDto(
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
m.Quote,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
m.ForwardedFromId,
m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new MessageSenderDto(fwd.Id, fwd.Username, fwd.DisplayName, fwd.Avatar) : null,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.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),
m.Reactions.Select(r => new SimpleReactionDto(r.UserId, r.Emoji)).ToList(),
m.ReadBy.Select(r => new ReadByDto(r.UserId)).ToList()
var result = messages.Select(message => new SearchMessageDto(
message.Id,
message.ChatId,
message.SenderId,
message.Content,
message.Type,
message.ReplyToId,
message.Quote,
message.IsEdited,
message.IsDeleted,
message.CreatedAt,
message.ForwardedFromId,
null,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
message.Reactions.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList(),
message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
)).ToList();
return Result.Success(result);
}
}

View File

@@ -27,15 +27,18 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly MediatR.IMediator _mediator;
public SendMessageCommandHandler(
IChatRepository chatRepository,
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork)
IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator)
{
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_mediator = mediator;
}
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
@@ -54,30 +57,71 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
}
// 3. Создаем сообщение
var message = Message.Create(
request.ChatId,
request.SenderId,
request.Content,
request.Type,
request.ReplyToId,
request.Quote,
request.ForwardedFromId,
request.StoryId,
request.StoryMediaUrl,
request.StoryMediaType);
if (request.Attachments != null && request.Attachments.Any())
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.SenderId,
request.StoryId ?? Guid.Empty,
request.StoryMediaUrl ?? string.Empty,
parsedStoryMediaType,
request.Content,
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)
{
message.AddMedia(att.Type, att.Url, att.FileName, att.FileSize);
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.Quote,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
// 4. Сохраняем
_messageRepository.Add(message);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _mediator.Publish(new MessageSentDomainEvent(
message.Id,
message.ChatId,
message.SenderId,
message.Content),
cancellationToken);
return Result.Success(message.Id);
}
}

View File

@@ -350,16 +350,24 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
bool isJoined = fromNameNode == null;
bool isMediaOnly = string.IsNullOrEmpty(content) && forwardedNode == null && replyToId == null;
bool hasMedia = mediaNodes != null && mediaNodes.Count > 0;
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;
}
else
{
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");
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");
if (!string.IsNullOrEmpty(href) && !href.StartsWith("http"))
@@ -391,8 +399,12 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
finalMType = "image";
}
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType);
targetMessage.AddMedia(finalMType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length);
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);
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));
}
}

View File

@@ -1,9 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain;
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;
@@ -24,12 +26,20 @@ public static class DependencyInjection
services.AddDbContext<ChatsDbContext>(options =>
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<IChatRepository, ChatRepository>();
services.AddScoped<IMessageRepository, MessageRepository>();
// Регистрация MediatR для этого модуля
// MediatR
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));

View File

@@ -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) { }
}

View File

@@ -7,6 +7,7 @@ public interface IMessageRepository
void Add(Message message);
Task<Message?> GetByIdAsync(Guid id, 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 AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, 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<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken);
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,12 @@
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Тип медиа-контента.
/// </summary>
public enum MediaType
{
Image,
Video,
Voice,
File
}

View File

@@ -1,89 +1,77 @@
using System;
using System.Collections.Generic;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
/// <summary>
/// Доменное событие: сообщение отправлено.
/// Абстрактная база агрегата Сообщение.
/// </summary>
public sealed record MessageSentDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : IDomainEvent;
/// <summary>
/// Сущность сообщения (Агрегат).
/// </summary>
public sealed class Message : AggregateRoot<Guid>
public abstract class Message : AggregateRoot<Guid>
{
public Guid ChatId { get; private set; }
public Guid SenderId { get; private set; }
public string? Content { get; private set; }
public string Type { get; private set; } // text, image, video, voice, file
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; }
// ================== Базовые поля ==================
public Guid ChatId { get; protected set; }
public Guid SenderId { get; protected set; }
public DateTime CreatedAt { get; protected set; }
// ================== Опциональные метаданные (общего назначения) ==================
public Guid? ReplyToId { get; protected set; }
public Guid? ForwardedFromId { get; protected set; }
// ================== Флаги ==================
public MessageState State { get; protected set; }
// ================== Абстрактные / Виртуальные свойства ==================
public abstract string Type { get; }
public abstract string? Content { get; protected set; }
private readonly List<Media> _media = new();
public IReadOnlyCollection<Media> Media => _media.AsReadOnly();
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>();
private readonly List<ReadReceipt> _readBy = new();
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();
private readonly List<Guid> _deletedByUsers = new();
public IReadOnlyCollection<Guid> DeletedByUsers => _deletedByUsers.AsReadOnly();
protected readonly List<DeletedMessage> _deletedFor = new();
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;
SenderId = senderId;
Content = content;
Type = type;
ReplyToId = replyToId;
Quote = quote;
ForwardedFromId = forwardedFromId;
StoryId = storyId;
StoryMediaUrl = storyMediaUrl;
StoryMediaType = storyMediaType;
CreatedAt = createdAt;
IsImported = isImported;
if (!isImported) // Don't trigger realtime events for historic messages
{
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content));
}
if (isImported) AddState(MessageState.IsImported);
}
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)
{
return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId, storyId, storyMediaUrl, storyMediaType, DateTime.UtcNow, false);
}
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 AddState(MessageState state) => State |= state;
public void RemoveState(MessageState state) => State &= ~state;
public bool HasState(MessageState state) => (State & state) == state;
// ================== Общие операции ==================
public void AddReaction(Guid userId, string 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;
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
AddState(MessageState.IsDeleted);
_reactions.Clear();
}
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;
}
}

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -77,14 +77,14 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
message.Id,
message.ChatId,
message.SenderId,
message.Content,
message.Type,
Content = message.Content,
Type = message.Type,
message.CreatedAt,
message.ForwardedFromId,
ForwardedFrom = forwardedFromObj,
message.ReplyToId,
ReplyTo = replyToObj,
message.Quote,
Quote = message.Quote,
Media = message.Media.Select(m => new
{
m.Id,
@@ -95,9 +95,10 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
}).ToList(),
Sender = senderObj,
ReadBy = new List<object>(),
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType
StoryId = message.StoryId,
StoryMediaUrl = message.StoryMediaUrl,
StoryMediaType = message.StoryMediaType
}, cancellationToken);
}
}

View File

@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Diagnostics;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Chats.Infrastructure.Persistence;
@@ -13,9 +14,9 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence;
public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
{
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)
{
_mediator = mediator;
@@ -23,9 +24,7 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
}
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)
@@ -54,61 +53,6 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
}).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)

View File

@@ -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.Shared.Kernel;
using System.Text.RegularExpressions;
using MongoDB.Bson;
namespace Knot.Modules.Chats.Infrastructure.Persistence;
public sealed class MessageRepository : IMessageRepository
{
private readonly IMongoCollection<Message> _messages;
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;
_mediator = mediator;
}
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)
{
return await _dbContext.Messages
.Include(m => m.Media)
.Include(m => m.ReadBy)
.Include(m => m.Reactions)
.FirstOrDefaultAsync(m => m.Id == id, cancellationToken);
var filter = Builders<Message>.Filter.Eq(m => m.Id, id);
return await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
}
public async Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken)
{
return await _dbContext.Messages
.Where(m => m.ChatId == chatId)
.OrderByDescending(m => m.CreatedAt)
var filter = Builders<Message>.Filter.Eq(m => m.ChatId, chatId);
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.Skip(offset)
.Take(limit)
.Include(m => m.Media)
.Include(m => m.ReadBy)
.Include(m => m.Reactions)
.Limit(limit)
.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)
{
var query = _dbContext.Messages
.Where(m => m.ChatId == chatId);
var builder = Builders<Message>.Filter;
var filter = builder.Eq(m => m.ChatId, chatId);
if (cursor.HasValue)
{
query = query.Where(m => m.CreatedAt < cursor.Value);
filter &= builder.Lt(m => m.CreatedAt, cursor.Value);
}
return await query
.OrderByDescending(m => m.CreatedAt)
.Take(limit)
.Include(m => m.Media)
.Include(m => m.ReadBy)
.Include(m => m.Reactions)
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.Limit(limit)
.ToListAsync(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
.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)
{
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))
.OrderByDescending(m => m.CreatedAt)
.Take(ChatConstants.SearchMessagesLimit)
.Include(m => m.Media)
.Include(m => m.ReadBy)
.Include(m => m.Reactions)
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.Limit(ChatConstants.SearchMessagesLimit)
.ToListAsync(cancellationToken);
}
public async Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken)
{
var existingReceipts = await _dbContext.ReadReceipts
.Where(r => r.UserId == userId && messageIds.Contains(r.MessageId))
.Select(r => r.MessageId)
.ToListAsync(cancellationToken);
var filter = Builders<Message>.Filter.In(m => m.Id, messageIds);
var newReceipts = messageIds
.Except(existingReceipts)
.Select(id => new ReadReceipt(id, userId));
var messages = await _messages.Find(filter).ToListAsync(cancellationToken);
var writes = new List<WriteModel<Message>>();
foreach (var msg in messages)
{
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);
}
}
_dbContext.ReadReceipts.AddRange(newReceipts);
// SaveChangesAsync будет вызван в handlers или через UnitOfWork, но если мы здесь
await _dbContext.SaveChangesAsync(cancellationToken);
if (writes.Any())
{
await _messages.BulkWriteAsync(writes, cancellationToken: cancellationToken);
}
}
public async Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
{
// Проверяем, существует ли сообщение
var messageExists = await _dbContext.Messages
.AnyAsync(m => m.Id == messageId, cancellationToken);
var filter = Builders<Message>.Filter.Eq(m => m.Id, messageId);
var msg = await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
if (msg == null) return false;
if (!messageExists)
if (msg.Reactions.Any(r => r.UserId == userId && r.Emoji == emoji))
{
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);
_dbContext.Reactions.Add(reaction);
var update = Builders<Message>.Update.Push("Reactions", reaction);
await _messages.UpdateOneAsync(filter, update, cancellationToken: cancellationToken);
return true;
}
public async Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
{
// Ищем реакцию
var reaction = await _dbContext.Reactions
.FirstOrDefaultAsync(r => r.MessageId == messageId && r.UserId == userId && r.Emoji == emoji, cancellationToken);
if (reaction == null)
{
return false;
}
_dbContext.Reactions.Remove(reaction);
var filter = Builders<Message>.Filter.Eq(m => m.Id, messageId);
var msg = await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
if (msg == null) return false;
var reaction = msg.Reactions.FirstOrDefault(r => r.UserId == userId && r.Emoji == emoji);
if (reaction == null) return false;
var update = Builders<Message>.Update.PullFilter("Reactions",
Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("UserId", userId),
Builders<BsonDocument>.Filter.Eq("Emoji", emoji)
));
await _messages.UpdateOneAsync(filter, update, cancellationToken: cancellationToken);
return true;
}
public async Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken)
{
return await _dbContext.Messages
.Where(m => m.ChatId == chatId && m.StoryId == storyId)
.OrderByDescending(m => m.CreatedAt)
var filter = Builders<Message>.Filter.And(
Builders<Message>.Filter.Eq(m => m.ChatId, chatId),
Builders<Message>.Filter.Eq("_t", "StoryMessage"),
Builders<Message>.Filter.Eq("StoryId", storyId)
);
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken)
{
return await _dbContext.Messages
.Where(m => m.ChatId == chatId && m.SenderId != userId && !m.ReadBy.Any(r => r.UserId == userId))
.CountAsync(cancellationToken);
var notReadFilter = Builders<Message>.Filter.Not(
Builders<Message>.Filter.ElemMatch("ReadBy",
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);
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
{
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -5,6 +5,9 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
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.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory;
@@ -124,7 +127,7 @@ public sealed class ChatHub : Hub
if (parsedIds.Any())
{
var command = new Knot.Modules.Chats.Application.Messages.Read.ReadMessagesCommand(
var command = new ReadMessagesCommand(
request.ChatId, _userContext.UserId, parsedIds);
await _sender.Send(command);
}
@@ -148,7 +151,7 @@ public sealed class ChatHub : Hub
if (parsedIds.Any())
{
var command = new Knot.Modules.Chats.Application.Messages.Delete.DeleteMessagesCommand(
var command = new DeleteMessagesCommand(
request.ChatId, _userContext.UserId, parsedIds, request.DeleteForAll);
await _sender.Send(command);
}
@@ -188,7 +191,7 @@ public sealed class ChatHub : Hub
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);
var result = await _sender.Send(command);
if (result.IsFailure)
@@ -209,7 +212,7 @@ public sealed class ChatHub : Hub
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);
var result = await _sender.Send(command);
if (result.IsFailure)

View File

@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.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="MongoDB.Driver" Version="3.7.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>

View File

@@ -1,18 +1,18 @@
// <auto-generated />
// <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;
using Knot.Modules.Chats.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
namespace Knot.Modules.Chats.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260311180825_InitialChats")]
[Migration("20260319124845_InitialChats")]
partial class InitialChats
{
/// <inheritdoc />
@@ -38,6 +38,9 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
@@ -50,45 +53,6 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
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 =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
@@ -103,6 +67,9 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
@@ -126,44 +93,6 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
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
}
}

View File

@@ -1,9 +1,9 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
namespace Knot.Modules.Chats.Migrations
{
/// <inheritdoc />
public partial class InitialChats : Migration
@@ -22,6 +22,7 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: true),
Description = 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)
},
@@ -30,27 +31,6 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
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(
name: "ChatMembers",
schema: "chats",
@@ -61,6 +41,7 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "text", 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)
},
constraints: table =>
@@ -75,42 +56,12 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
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(
name: "IX_ChatMembers_ChatId_UserId",
schema: "chats",
table: "ChatMembers",
columns: new[] { "ChatId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MessageMedia_MessageId",
schema: "chats",
table: "MessageMedia",
column: "MessageId");
}
/// <inheritdoc />
@@ -120,17 +71,9 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations
name: "ChatMembers",
schema: "chats");
migrationBuilder.DropTable(
name: "MessageMedia",
schema: "chats");
migrationBuilder.DropTable(
name: "Chats",
schema: "chats");
migrationBuilder.DropTable(
name: "Messages",
schema: "chats");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using MediatR;
using NSubstitute;
using Xunit;
using Knot.Shared.Kernel;
@@ -18,6 +19,7 @@ public class SendMessageCommandHandlerTests
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMediator _mediator;
private readonly SendMessageCommandHandler _handler;
public SendMessageCommandHandlerTests()
@@ -25,8 +27,9 @@ public class SendMessageCommandHandlerTests
_chatRepository = Substitute.For<IChatRepository>();
_messageRepository = Substitute.For<IMessageRepository>();
_unitOfWork = Substitute.For<IChatsUnitOfWork>();
_mediator = Substitute.For<IMediator>();
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork);
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator);
}
[Fact]

View File

@@ -21,6 +21,7 @@ services:
depends_on:
- db
- minio
- mongo
ports:
- "5059:8080"
@@ -36,6 +37,14 @@ services:
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
mongo:
image: mongo:6-jammy
container_name: knot-mongo
restart: always
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
web:
build: