Перепиливание под чистый DDD
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+106
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+50
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -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>
|
||||
Reference in New Issue
Block a user