Перепиливание под чистый DDD

This commit is contained in:
Халимов Рустам
2026-03-22 23:59:33 +03:00
parent 5da1a2f45d
commit 6e532b021d
302 changed files with 3595 additions and 3679 deletions
@@ -0,0 +1,6 @@
namespace Knot.Modules.Messaging.Application.Abstractions;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
public interface IChatAccessProvider { Task<List<Guid>> GetValidChatIdsForUserAsync(Guid userId, CancellationToken ct); }
@@ -0,0 +1,5 @@
namespace Knot.Modules.Messaging.Application.Abstractions;
using System;
using System.Threading;
using System.Threading.Tasks;
public interface IMessageNotifier { Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken); }
@@ -0,0 +1,46 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel;
using MongoDB.Driver;
using Knot.Modules.Messaging.Infrastructure.Persistence;
using Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
using Knot.Modules.Messaging.Application.Abstractions;
namespace Knot.Modules.Messaging;
public static class DependencyInjection
{
/// <summary>
/// ╨а╨╡╨│╨╕╤Б╤В╤А╨░╤Ж╨╕╤П ╤Б╨╡╤А╨▓╨╕╤Б╨╛╨▓ ╨╝╨╛╨┤╤Г╨╗╤П Chats.
/// </summary>
public static IServiceCollection AddMessagingModule(
this IServiceCollection services,
IConfiguration configuration)
{
// ╨Э╨░╤Б╤В╤А╨╛╨╣╨║╨░ ╨▒╨░╨╖╤Л ╨┤╨░╨╜╨╜╤Л╤Е
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
// 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<IMessageRepository, MessageRepository>();
services.AddScoped<IMessageReactionRepository, MessageReactionRepository>();
// MediatR
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
return services;
}
}
@@ -0,0 +1,23 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.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) { }
}
@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Messaging.Domain;
public interface IMessageReactionRepository
{
Task AddAsync(MessageReaction reaction, CancellationToken cancellationToken);
Task RemoveAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
Task<List<MessageReaction>> GetReactionsForMessageAsync(Guid messageId, CancellationToken cancellationToken);
Task<List<MessageReaction>> GetReactionsForMessagesAsync(IEnumerable<Guid> messageIds, CancellationToken cancellationToken);
}
@@ -0,0 +1,19 @@
using Knot.Modules.Messaging.Domain;
namespace Knot.Modules.Messaging.Domain;
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<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken);
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken);
}
@@ -0,0 +1,33 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Domain;
/// <summary>
/// Медиа-файл, прикрепленный к сообщению.
/// </summary>
public sealed class Media : Entity<Guid>
{
public Guid MessageId { get; private set; }
public string Type { get; private set; }
public string Url { get; private set; }
public string? Filename { get; private set; }
public long? Size { get; private set; }
internal Media(Guid messageId, string type, string url, string? filename, long? size) : base(Guid.NewGuid())
{
MessageId = messageId;
Type = type;
Url = url;
Filename = filename;
Size = size;
}
private Media() : base(Guid.Empty)
{
Type = string.Empty;
Url = string.Empty;
}
}
@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Messaging.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 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 AppendImportedCaption(string additionalCaption)
{
if (string.IsNullOrEmpty(Caption))
{
Caption = additionalCaption;
}
else
{
Caption += "\n" + additionalCaption;
}
}
public void Edit(string newCaption)
{
Caption = newCaption;
AddState(MessageState.IsEdited);
}
public override void Delete()
{
Caption = null;
base.Delete();
}
}
@@ -0,0 +1,14 @@
namespace Knot.Modules.Messaging.Domain;
/// <summary>
/// Тип медиа-контента.
/// </summary>
public enum MediaType
{
Image,
Video,
Voice,
File
}
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Domain;
/// <summary>
/// Абстрактная база агрегата Сообщение.
/// </summary>
public abstract class Message : AggregateRoot<Guid>
{
// ================== Базовые поля ==================
public Guid ChatId { get; protected set; }
public Guid SenderId { get; protected set; }
public DateTime CreatedAt { get; protected set; }
public long SequenceId { get; protected set; }
public void SetSequenceId(long sequenceId)
{
SequenceId = sequenceId;
}
// ================== Опциональные метаданные (общего назначения) ==================
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; }
public virtual string? Quote { get; protected set; } = null;
public virtual Guid? StoryId => null;
public virtual string? StoryMediaUrl => null;
public virtual string? StoryMediaType => null;
public virtual IReadOnlyCollection<Media> Media => Array.Empty<Media>();
public bool IsEdited => HasState(MessageState.IsEdited);
public bool IsDeleted => HasState(MessageState.IsDeleted);
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
// ================== Связанные коллекции (общего назначения) ==================
protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
// ================== Инфраструктурный конструктор 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;
ReplyToId = replyToId;
ForwardedFromId = forwardedFromId;
CreatedAt = createdAt;
if (isImported) AddState(MessageState.IsImported);
}
// ================== Управление Состоянием ==================
public void AddState(MessageState state) => State |= state;
public void RemoveState(MessageState state) => State &= ~state;
public bool HasState(MessageState state) => (State & state) == state;
// ================== Общие операции ==================
public virtual void Delete()
{
AddState(MessageState.IsDeleted);
}
public void DeleteForUser(Guid userId)
{
if (!_deletedFor.Exists(x => x.UserId == userId))
{
_deletedFor.Add(new DeletedMessage(Id, userId));
}
}
}
@@ -0,0 +1,18 @@
using System;
namespace Knot.Modules.Messaging.Domain;
/// <summary>
/// Флаги состояния сообщения
/// </summary>
[Flags]
public enum MessageFlags
{
None = 0,
IsEdited = 1,
IsDeleted = 2,
IsImported = 4,
IsPinned = 8
}
@@ -0,0 +1,31 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Domain;
/// <summary>
/// Агрегат/Сущность реакции для сообщения. Вынесен в отдельную коллекцию
/// для бесконечного масштабирования и чистоты DDD (Approach 3).
/// </summary>
public sealed class MessageReaction : AggregateRoot<Guid>
{
public Guid MessageId { get; private set; }
public Guid UserId { get; private set; }
public string Emoji { get; private set; }
public DateTime CreatedAt { get; private set; }
private MessageReaction() : base(Guid.Empty)
{
Emoji = default!;
}
public MessageReaction(Guid messageId, Guid userId, string emoji) : base(Guid.NewGuid())
{
MessageId = messageId;
UserId = userId;
Emoji = emoji;
CreatedAt = DateTime.UtcNow;
}
}
@@ -0,0 +1,11 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Domain;
/// <summary>
/// Доменное событие: сообщение отправлено.
/// </summary>
public sealed record MessageSentDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : IDomainEvent;
@@ -0,0 +1,18 @@
using System;
namespace Knot.Modules.Messaging.Domain;
/// <summary>
/// Состояние сообщения
/// </summary>
[Flags]
public enum MessageState
{
None = 0,
IsEdited = 1,
IsDeleted = 2,
IsImported = 4,
IsPinned = 8
}
@@ -0,0 +1,28 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Domain;
/// <summary>
/// Реакция на сообщение.
/// </summary>
public sealed class Reaction : Entity<Guid>
{
public Guid MessageId { get; private set; }
public Guid UserId { get; private set; }
public string Emoji { get; private set; }
internal Reaction(Guid messageId, Guid userId, string emoji) : base(Guid.NewGuid())
{
MessageId = messageId;
UserId = userId;
Emoji = emoji;
}
private Reaction() : base(Guid.Empty)
{
Emoji = string.Empty;
}
}
@@ -0,0 +1,22 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Domain;
public sealed class ReadReceipt : Entity<Guid>
{
public Guid MessageId { get; private set; }
public Guid UserId { get; private set; }
public DateTime ReadAt { get; private set; }
// Для EF Core
private ReadReceipt() : base(Guid.Empty) { }
public ReadReceipt(Guid messageId, Guid userId) : base(Guid.NewGuid())
{
MessageId = messageId;
UserId = userId;
ReadAt = DateTime.UtcNow;
}
}
@@ -0,0 +1,57 @@
using System;
namespace Knot.Modules.Messaging.Domain;
public class StoryMessage : Message
{
public override string Type => "story";
public override string? Content { get; protected set; }
public Guid InternalStoryId { get; private set; }
public override Guid? StoryId => InternalStoryId;
public string InternalStoryMediaUrl { get; private set; }
public override string? StoryMediaUrl => InternalStoryMediaUrl;
public MediaType InternalStoryMediaType { get; private set; }
public override string? StoryMediaType => InternalStoryMediaType.ToString().ToLower();
private StoryMessage() : base()
{
InternalStoryMediaUrl = string.Empty;
InternalStoryMediaType = MediaType.Image;
}
public StoryMessage(
Guid id,
Guid chatId,
Guid senderId,
Guid storyId,
string storyMediaUrl,
MediaType storyMediaType,
string? content,
Guid? replyToId,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
InternalStoryId = storyId;
InternalStoryMediaUrl = storyMediaUrl;
InternalStoryMediaType = storyMediaType;
Content = content;
if (!isImported)
{
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content));
}
}
public override void Delete()
{
Content = null;
base.Delete();
}
}
@@ -0,0 +1,50 @@
using System;
namespace Knot.Modules.Messaging.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();
}
}
@@ -0,0 +1,106 @@
using MediatR;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Infrastructure.Handlers;
/// <summary>
/// Обработчик доменного события отправки сообщения.
/// Отправляет уведомление через SignalR всем участникам чата.
/// </summary>
public sealed class MessageSentDomainEventHandler : INotificationHandler<MessageSentDomainEvent>
{
private readonly IMessageNotifier _hubContext;
private readonly IMessageRepository _messageRepository;
private readonly IUserDisplayNameProvider _displayNameProvider;
public MessageSentDomainEventHandler(
IMessageNotifier hubContext,
IMessageRepository messageRepository,
IUserDisplayNameProvider displayNameProvider)
{
_hubContext = hubContext;
_messageRepository = messageRepository;
_displayNameProvider = displayNameProvider;
}
public async Task Handle(MessageSentDomainEvent notification, CancellationToken cancellationToken)
{
var message = await _messageRepository.GetByIdAsync(notification.MessageId, cancellationToken);
if (message is null)
{
return;
}
var userInfo = await _displayNameProvider.GetUserInfoAsync(message.SenderId, cancellationToken);
var senderObj = userInfo != null
? new { Id = userInfo.Id, Username = userInfo.Username, DisplayName = userInfo.DisplayName, Avatar = userInfo.Avatar }
: new { Id = message.SenderId, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null };
// Fetch forwarded from info if exists
object? forwardedFromObj = null;
if (message.ForwardedFromId.HasValue)
{
var fwdUserInfo = await _displayNameProvider.GetUserInfoAsync(message.ForwardedFromId.Value, cancellationToken);
forwardedFromObj = fwdUserInfo != null
? new { Id = fwdUserInfo.Id, Username = fwdUserInfo.Username, DisplayName = fwdUserInfo.DisplayName, Avatar = fwdUserInfo.Avatar }
: new { Id = message.ForwardedFromId.Value, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null };
}
// Fetch reply info if exists
object? replyToObj = null;
if (message.ReplyToId.HasValue)
{
var replyMsg = await _messageRepository.GetByIdAsync(message.ReplyToId.Value, cancellationToken);
if (replyMsg != null)
{
var replySenderInfo = await _displayNameProvider.GetUserInfoAsync(replyMsg.SenderId, cancellationToken);
replyToObj = new
{
Id = replyMsg.Id,
Content = replyMsg.Content,
Quote = message.Quote,
media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(),
Sender = replySenderInfo != null
? new { Id = replySenderInfo.Id, Username = replySenderInfo.Username, DisplayName = replySenderInfo.DisplayName }
: new { Id = replyMsg.SenderId, Username = "unknown", DisplayName = "Unknown" }
};
}
}
// Отправляем сообщение в "комнату" чата.
await _hubContext.NotifyNewMessageAsync(notification.ChatId, new
{
id = message.Id,
chatId = message.ChatId,
senderId = message.SenderId,
content = message.Content,
type = message.Type,
createdAt = message.CreatedAt,
forwardedFromId = message.ForwardedFromId,
forwardedFrom = forwardedFromObj,
replyToId = message.ReplyToId,
replyTo = replyToObj,
quote = message.Quote,
media = message.Media.Select(m => new
{
id = m.Id,
type = m.Type,
url = m.Url,
filename = m.Filename,
size = m.Size
}).ToList(),
sender = senderObj,
readBy = new List<object>(),
storyId = message.StoryId,
storyMediaUrl = message.StoryMediaUrl,
storyMediaType = message.StoryMediaType
}, cancellationToken);
}
}
@@ -0,0 +1,55 @@
using MongoDB.Driver;
using Knot.Modules.Messaging.Domain;
namespace Knot.Modules.Messaging.Infrastructure.Persistence;
public sealed class MessageReactionRepository : IMessageReactionRepository
{
private readonly IMongoCollection<MessageReaction> _reactions;
public MessageReactionRepository(IMongoDatabase mongoDatabase)
{
_reactions = mongoDatabase.GetCollection<MessageReaction>("message_reactions");
// Ensure index for fast querying by message
var indexKeysDefinition = Builders<MessageReaction>.IndexKeys.Ascending(r => r.MessageId);
_reactions.Indexes.CreateOne(new CreateIndexModel<MessageReaction>(indexKeysDefinition));
}
public async Task AddAsync(MessageReaction reaction, CancellationToken cancellationToken)
{
// Уникальный индекс или фильтр, чтобы не дублировать
var filter = Builders<MessageReaction>.Filter.And(
Builders<MessageReaction>.Filter.Eq(r => r.MessageId, reaction.MessageId),
Builders<MessageReaction>.Filter.Eq(r => r.UserId, reaction.UserId),
Builders<MessageReaction>.Filter.Eq(r => r.Emoji, reaction.Emoji)
);
// Используем ReplaceOptions.IsUpsert = true для идемпотентности (нет гонок)
await _reactions.ReplaceOneAsync(filter, reaction, new ReplaceOptions { IsUpsert = true }, cancellationToken);
}
public async Task RemoveAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
{
var filter = Builders<MessageReaction>.Filter.And(
Builders<MessageReaction>.Filter.Eq(r => r.MessageId, messageId),
Builders<MessageReaction>.Filter.Eq(r => r.UserId, userId),
Builders<MessageReaction>.Filter.Eq(r => r.Emoji, emoji)
);
await _reactions.DeleteOneAsync(filter, cancellationToken);
}
public async Task<List<MessageReaction>> GetReactionsForMessageAsync(Guid messageId, CancellationToken cancellationToken)
{
return await _reactions.Find(r => r.MessageId == messageId).ToListAsync(cancellationToken);
}
public async Task<List<MessageReaction>> GetReactionsForMessagesAsync(IEnumerable<Guid> messageIds, CancellationToken cancellationToken)
{
var filter = Builders<MessageReaction>.Filter.In(r => r.MessageId, messageIds);
return await _reactions.Find(filter).ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,135 @@
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
using MongoDB.Driver.Linq;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel;
using System.Text.RegularExpressions;
using MongoDB.Bson;
namespace Knot.Modules.Messaging.Infrastructure.Persistence;
public sealed class MessageRepository : IMessageRepository
{
private readonly IMongoCollection<Message> _messages;
private readonly Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider _chatAccessProvider;
private readonly MediatR.IMediator _mediator;
public MessageRepository(IMongoDatabase mongoDatabase, Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider chatAccessProvider, MediatR.IMediator mediator)
{
_messages = mongoDatabase.GetCollection<Message>("messages");
_chatAccessProvider = chatAccessProvider;
_mediator = mediator;
}
public void Add(Message 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)
{
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)
{
var filter = Builders<Message>.Filter.Eq(m => m.ChatId, chatId);
return await _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.Skip(offset)
.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 builder = Builders<Message>.Filter;
var filter = builder.Eq(m => m.ChatId, chatId);
if (cursor.HasValue)
{
filter &= builder.Lt(m => m.CreatedAt, cursor.Value);
}
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 = await _chatAccessProvider.GetValidChatIdsForUserAsync(requestingUserId, cancellationToken);
var builder = Builders<Message>.Filter;
var filter = builder.In(m => m.ChatId, validChatIdsQuery);
if (chatId.HasValue)
{
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 _messages.Find(filter)
.SortByDescending(m => m.CreatedAt)
.Limit(50)
.ToListAsync(cancellationToken);
}
public async Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken)
{
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 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);
}
}
}
@@ -0,0 +1,50 @@
using System;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
public class EncryptedStringSerializer : SerializerBase<string>
{
public static IEncryptionService? EncryptionService { get; set; }
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, string value)
{
if (string.IsNullOrEmpty(value) || EncryptionService == null)
{
context.Writer.WriteString(value ?? string.Empty);
return;
}
try
{
var encrypted = EncryptionService.EncryptMessage(value);
context.Writer.WriteString(encrypted);
}
catch
{
context.Writer.WriteString(value);
}
}
public override string Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
{
var value = context.Reader.ReadString();
if (string.IsNullOrEmpty(value) || EncryptionService == null)
{
return value;
}
try
{
return EncryptionService.DecryptMessage(value);
}
catch
{
// Fallback for already unencrypted, or failed to decrypt
return value;
}
}
}
@@ -0,0 +1,75 @@
using Knot.Modules.Messaging.Domain;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.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.MapProperty(c => c.Content).SetSerializer(new EncryptedStringSerializer());
cm.MapProperty(c => c.Quote).SetSerializer(new EncryptedStringSerializer());
cm.SetIsRootClass(true);
});
BsonClassMap.RegisterClassMap<TextMessage>(cm =>
{
cm.AutoMap();
cm.SetDiscriminator("TextMessage");
});
BsonClassMap.RegisterClassMap<MediaMessage>(cm =>
{
cm.AutoMap();
cm.MapField("_media").SetElementName("Media");
cm.SetDiscriminator("MediaMessage");
cm.UnmapProperty(c => c.Caption); // Avoid DB duplication, Content is already saved
});
BsonClassMap.RegisterClassMap<StoryMessage>(cm =>
{
cm.AutoMap();
cm.SetDiscriminator("StoryMessage");
cm.MapProperty(c => c.InternalStoryMediaUrl).SetSerializer(new EncryptedStringSerializer());
});
BsonClassMap.RegisterClassMap<DeletedMessage>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<MessageReaction>(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;
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
<PackageReference Include="MongoDB.Driver" Version="3.0.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>