Compare commits
57
Commits
d3f1e3f361
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d5764f6e | ||
|
|
786eaffb33 | ||
|
|
4940f1212f | ||
|
|
c166f1d186 | ||
|
|
71b3d2b491 | ||
|
|
a5d40f28c8 | ||
|
|
9bfc5555bc | ||
|
|
7eea8ff6d1 | ||
|
|
72325f48e5 | ||
|
|
2b51375fbf | ||
|
|
c92289f074 | ||
|
|
33ea792941 | ||
|
|
63fc0e197b | ||
|
|
83ed328dd5 | ||
|
|
0f593e52e0 | ||
|
|
d9462069e2 | ||
|
|
9e715fe3ab | ||
|
|
70acad56fb | ||
|
|
8399d32490 | ||
|
|
3905094ff4 | ||
|
|
9e8625aea1 | ||
|
|
5b905c94da | ||
|
|
0eecc01374 | ||
|
|
c37e1723d4 | ||
|
|
02043d4d97 | ||
|
|
852efa090e | ||
|
|
c45f4db61c | ||
|
|
02a85fc587 | ||
|
|
e09860700c | ||
|
|
32c9bc43cf | ||
|
|
d96e4ec7d4 | ||
|
|
1558b20470 | ||
|
|
fa185afc73 | ||
|
|
90096ce2bc | ||
|
|
65d5f5fee9 | ||
|
|
e11240f78f | ||
|
|
53f193970c | ||
|
|
f41ad0bcf8 | ||
|
|
b49ff47762 | ||
|
|
182bbe2ad8 | ||
|
|
a04f04a448 | ||
|
|
2ab5b295e8 | ||
|
|
1b8abbc995 | ||
|
|
943699139b | ||
|
|
757142eda7 | ||
|
|
a42007df2d | ||
|
|
9df7d7aaf1 | ||
|
|
4ae7dd60ce | ||
|
|
249c344df8 | ||
|
|
2c6d6f831f | ||
|
|
bce8f92101 | ||
|
|
aa69c44a64 | ||
|
|
4faa7561a0 | ||
|
|
8025340e45 | ||
|
|
60d3cf12ae | ||
|
|
b09e197626 | ||
|
|
f1f8f6e012 |
@@ -0,0 +1,7 @@
|
||||
using Knot.Shared.Kernel;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Contracts.Auth.Application.Abstractions;
|
||||
|
||||
public record GetUsersExistenceQuery(List<Guid> UserIds) : IQuery<List<Guid>>;
|
||||
@@ -4,6 +4,7 @@ public interface IJwtTokenProvider
|
||||
{
|
||||
string GenerateAccessToken(Guid userId, string username);
|
||||
string GenerateRefreshToken();
|
||||
DateTime GetRefreshTokenExpiry();
|
||||
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||
string Generate(Domain.UserContract user);
|
||||
}
|
||||
|
||||
@@ -7,5 +7,7 @@ public static class AuthErrors
|
||||
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
|
||||
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
|
||||
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
|
||||
public static Error IdentityRegistrationFailed => new("Auth.RegistrationFailed", "Failed to register user");
|
||||
public static Error RefreshTokenExpired => new("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again.");
|
||||
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
namespace Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
|
||||
public class AuthResponseDto
|
||||
{
|
||||
@@ -7,6 +7,7 @@ public class AuthResponseDto
|
||||
public Guid UserId { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Avatar { get; set; }
|
||||
}
|
||||
|
||||
public class ResetPasswordDto
|
||||
|
||||
@@ -19,4 +19,12 @@ public class UserContract
|
||||
public bool IsExternal { get; set; }
|
||||
public string? Domain { get; set; }
|
||||
public DateTime? LastSeen { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public DateTime? RefreshTokenExpiry { get; set; }
|
||||
|
||||
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||
{
|
||||
RefreshToken = refreshToken;
|
||||
RefreshTokenExpiry = expiry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,4 @@ public interface IChatQueryService
|
||||
Task<List<ChatInfo>> GetAllChatsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IUserDeleterService
|
||||
{
|
||||
Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public record ChatInfo(Guid Id, string? Avatar);
|
||||
|
||||
+2
-2
@@ -3,9 +3,9 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Abstractions;
|
||||
namespace Knot.Contracts.Conversations.Abstractions;
|
||||
|
||||
public interface IUserDeleterService
|
||||
{
|
||||
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -36,17 +36,21 @@ public sealed class Chat : AggregateRoot<Guid>
|
||||
public string? Avatar { get; private set; }
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
public long LastMessageSequenceId { get; private set; }
|
||||
public bool IsImporting { get; private set; }
|
||||
public Guid? ImportJobId { get; private set; }
|
||||
|
||||
private readonly List<ChatMember> _members = new();
|
||||
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
|
||||
|
||||
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null) : base(id)
|
||||
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null, bool isImporting = false, Guid? importJobId = null) : base(id)
|
||||
{
|
||||
Type = type;
|
||||
Name = name;
|
||||
Avatar = avatar;
|
||||
Description = description;
|
||||
CreatedAt = DateTime.UtcNow;
|
||||
IsImporting = isImporting;
|
||||
ImportJobId = importJobId;
|
||||
}
|
||||
|
||||
public static Chat CreatePersonal()
|
||||
@@ -63,13 +67,18 @@ public sealed class Chat : AggregateRoot<Guid>
|
||||
return chat;
|
||||
}
|
||||
|
||||
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null)
|
||||
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null, bool isImporting = false, Guid? importJobId = null)
|
||||
{
|
||||
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description);
|
||||
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description, isImporting, importJobId);
|
||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
||||
return chat;
|
||||
}
|
||||
|
||||
public void CompleteImport()
|
||||
{
|
||||
IsImporting = false;
|
||||
}
|
||||
|
||||
public void AddMember(Guid userId, string role = "member")
|
||||
{
|
||||
if (_members.Any(m => m.UserId == userId))
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Knot.Contracts.Conversations.Domain;
|
||||
|
||||
public static class ChatConstants
|
||||
{
|
||||
public const int DefaultMessageQueryLimit = 50;
|
||||
public const int MaxSharedMediaQueryLimit = 1000;
|
||||
public const int MaxGroupNameLength = 100;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Contracts.Conversations.Domain;
|
||||
|
||||
public static class ChatErrors
|
||||
{
|
||||
public static readonly Error ChatNotFound = new Error("Chat.NotFound", "Чат не найден");
|
||||
public static readonly Error OnlyOwnerCanUpdate = new Error("Chat.OnlyOwnerCanUpdate", "Только владелец может редактировать чат");
|
||||
public static readonly Error Unauthorized = new Error("Chat.Unauthorized", "Нет доступа к этому чату");
|
||||
public static readonly Error FoldersDisabled = new Error("Chat.FoldersDisabled", "Папки отключены");
|
||||
public static readonly Error FileEmpty = new Error("Chat.FileEmpty", "Файл пуст");
|
||||
public static Error FileTooLarge(long maxMb) => new Error("Chat.FileTooLarge", $"Файл слишком большой (максимум {maxMb} МБ)");
|
||||
public static readonly Error ChatsNotFound = new Error("Chat.NotFound", "Чат не найден");
|
||||
public static readonly Error ChatsForbidden = new Error("Chat.Forbidden", "Доступ запрещен");
|
||||
public static readonly Error MediaDisabled = new Error("Chat.MediaDisabled", "Медиафайлы отключены");
|
||||
public static readonly Error PollsDisabled = new Error("Chat.PollsDisabled", "Опросы отключены");
|
||||
public static readonly Error NotFound = new Error("Chat.NotFound", "Не найдено");
|
||||
public static readonly Error NotMember = new Error("Chat.NotMember", "Вы не являетесь участником чата");
|
||||
}
|
||||
@@ -3,4 +3,5 @@ namespace Knot.Contracts.Messaging.Application.Abstractions;
|
||||
public interface IMessageNotifier
|
||||
{
|
||||
Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken);
|
||||
Task NotifyMessageUpdateAsync(Guid chatId, string updateType, object updatePayload, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ public interface IMessageRepository
|
||||
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>> GetPinnedMessagesAsync(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<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
|
||||
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
||||
Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, 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;
|
||||
|
||||
namespace Knot.Contracts.Messaging.Domain;
|
||||
|
||||
public class CallMessage : Message
|
||||
{
|
||||
public override string Type => "call";
|
||||
public override string? Content { get; protected set; }
|
||||
public string CallType { get; protected set; }
|
||||
public string CallStatus { get; protected set; }
|
||||
public int? Duration { get; protected set; }
|
||||
|
||||
public CallMessage() : base() { }
|
||||
|
||||
public CallMessage(
|
||||
Guid id,
|
||||
Guid chatId,
|
||||
Guid senderId,
|
||||
string callType,
|
||||
string callStatus,
|
||||
int? duration,
|
||||
Guid? replyToId,
|
||||
Guid? forwardedFromId,
|
||||
DateTime createdAt,
|
||||
bool isImported = false)
|
||||
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
|
||||
{
|
||||
CallType = callType;
|
||||
CallStatus = callStatus;
|
||||
Duration = duration;
|
||||
Content = $"Call {callStatus}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Contracts.Messaging.Domain;
|
||||
|
||||
public class Media
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string? Url { get; set; }
|
||||
public string? ThumbnailUrl { get; set; }
|
||||
public long? Size { get; set; }
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public string? FileId { get; set; }
|
||||
public string? Filename { get; set; }
|
||||
public string? Duration { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Contracts.Messaging.Domain;
|
||||
|
||||
public class MediaMessage : Message
|
||||
{
|
||||
public override string Type => MediaType.ToString().ToLower();
|
||||
public override string? Content { get; protected set; }
|
||||
public string? Caption { get => Content; private set => Content = value; }
|
||||
public MediaType MediaType { get; private set; }
|
||||
|
||||
private List<Media> _media = new();
|
||||
public 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; }
|
||||
|
||||
public MediaMessage(Guid id, Guid chatId, Guid senderId, string mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||
: this(id, chatId, senderId, Enum.TryParse<MediaType>(mediaType, true, out var mt) ? mt : MediaType.File, caption, replyToId, forwardedFromId, createdAt, isImported) { }
|
||||
|
||||
public void AddMedia(string type, string url, string? filename, long? size, string? duration = null) => _media.Add(new Media { Type = type, Url = url, Filename = filename, FileId = filename, Size = size, Duration = duration });
|
||||
|
||||
public override void Edit(string newCaption) => base.Edit(newCaption);
|
||||
|
||||
public override void Delete() { Caption = null; base.Delete(); }
|
||||
}
|
||||
@@ -16,18 +16,20 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
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();
|
||||
|
||||
protected List<Guid> _readByUsers = new();
|
||||
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
|
||||
|
||||
protected Message() : base(Guid.Empty) { }
|
||||
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) : base(id)
|
||||
|
||||
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||
: base(id)
|
||||
{
|
||||
ChatId = chatId;
|
||||
SenderId = senderId;
|
||||
@@ -36,91 +38,32 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
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 bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
|
||||
|
||||
public virtual void Delete() => AddState(MessageState.IsDeleted);
|
||||
public virtual void Edit(string newContent) { Content = newContent; AddState(MessageState.IsEdited); }
|
||||
public void DeleteForUser(Guid userId) { if (!_deletedFor.Exists(x => x.UserId == userId)) _deletedFor.Add(new DeletedMessage(Id, userId)); }
|
||||
}
|
||||
|
||||
public class Media
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string? Url { get; set; }
|
||||
public string? ThumbnailUrl { get; set; }
|
||||
public long? Size { get; set; }
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public string? FileId { get; set; }
|
||||
public string? Filename { get; set; }
|
||||
public string? Duration { get; set; }
|
||||
}
|
||||
|
||||
public class TextMessage : Message
|
||||
{
|
||||
public override string Type => "text";
|
||||
public override string? Content { get; protected set; }
|
||||
public TextMessage() : base() { }
|
||||
public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported = false)
|
||||
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) => Content = content;
|
||||
public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, string? quote, Guid? forwardedFromId, DateTime createdAt, bool isImported = false)
|
||||
: this(id, chatId, senderId, content, replyToId, forwardedFromId, createdAt, isImported) => Quote = quote;
|
||||
}
|
||||
|
||||
public class MediaMessage : Message
|
||||
{
|
||||
public override string Type => MediaType.ToString().ToLower();
|
||||
public override string? Content { get; protected set; }
|
||||
public string? Caption { get => Content; private set => Content = value; }
|
||||
public MediaType MediaType { get; private set; }
|
||||
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; }
|
||||
public MediaMessage(Guid id, Guid chatId, Guid senderId, string mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||
: this(id, chatId, senderId, Enum.TryParse<MediaType>(mediaType, true, out var mt) ? mt : MediaType.File, caption, replyToId, forwardedFromId, createdAt, isImported) { }
|
||||
public void AddMedia(string type, string url, string? filename, long? size) => _media.Add(new Media { Type = type, Url = url, FileId = filename, Size = size });
|
||||
public override void Edit(string newCaption) => base.Edit(newCaption);
|
||||
public override void Delete() { Caption = null; base.Delete(); }
|
||||
}
|
||||
|
||||
public class StoryMessage : Message
|
||||
{
|
||||
public override string Type => "story";
|
||||
public override string? Content { get; protected set; }
|
||||
public override Guid? StoryId { get; }
|
||||
public string? InternalStoryMediaUrl { get; private set; }
|
||||
public override string? StoryMediaUrl => InternalStoryMediaUrl;
|
||||
public override string? StoryMediaType { get; }
|
||||
public StoryMessage() : base() { }
|
||||
public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, DateTime createdAt)
|
||||
: base(id, chatId, senderId, null, null, createdAt, false) { StoryId = storyId; InternalStoryMediaUrl = storyMediaUrl; StoryMediaType = storyMediaType; }
|
||||
public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, string? content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||
: this(id, chatId, senderId, storyId, storyMediaUrl, storyMediaType, createdAt) { Content = content; ReplyToId = replyToId; ForwardedFromId = forwardedFromId; if (isImported) AddState(MessageState.IsImported); }
|
||||
}
|
||||
|
||||
public class PollMessage : Message
|
||||
{
|
||||
public override string Type => "poll";
|
||||
public override string? Content { get; protected set; }
|
||||
public List<PollOption> Options { get; } = new();
|
||||
public List<PollVote> Votes { get; } = new();
|
||||
public bool IsMultipleChoice { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public bool IsClosed { get; set; }
|
||||
public PollMessage() : base() { }
|
||||
public PollMessage(Guid id, Guid chatId, Guid senderId, string? question, List<string>? options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
|
||||
public virtual void Edit(string newContent)
|
||||
{
|
||||
Content = question ?? "Poll";
|
||||
if (options != null) foreach (var opt in options) Options.Add(new PollOption { Text = opt });
|
||||
IsMultipleChoice = isMultiple;
|
||||
ExpiresAt = expiresAt;
|
||||
Content = newContent;
|
||||
AddState(MessageState.IsEdited);
|
||||
}
|
||||
}
|
||||
|
||||
public class PollOption { public string Text { get; set; } = string.Empty; public int VoteCount { get; set; } }
|
||||
public class PollVote { public Guid OptionIndex { get; set; } public Guid UserId { get; set; } public DateTime VotedAt { get; set; } }
|
||||
public void DeleteForUser(Guid userId)
|
||||
{
|
||||
if (!_deletedFor.Exists(x => x.UserId == userId))
|
||||
_deletedFor.Add(new DeletedMessage(Id, userId));
|
||||
}
|
||||
|
||||
public void MarkAsRead(Guid userId)
|
||||
{
|
||||
if (!_readByUsers.Contains(userId))
|
||||
{
|
||||
_readByUsers.Add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Contracts.Messaging.Domain;
|
||||
|
||||
public class PollMessage : Message
|
||||
{
|
||||
public override string Type => "poll";
|
||||
public override string? Content { get; protected set; }
|
||||
public List<PollOption> Options { get; set; } = new();
|
||||
public List<PollVote> Votes { get; set; } = new();
|
||||
public bool IsMultipleChoice { get; set; }
|
||||
public bool IsAnonymous { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public bool IsClosed { get; set; }
|
||||
|
||||
public PollMessage() : base() { }
|
||||
|
||||
public PollMessage(Guid id, Guid chatId, Guid senderId, string? question, List<PollOption>? options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
|
||||
{
|
||||
Content = question ?? "Poll";
|
||||
Options = options ?? new List<PollOption>();
|
||||
IsAnonymous = isAnonymous;
|
||||
IsMultipleChoice = isMultiple;
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public static PollMessage Create(Guid id, Guid chatId, Guid senderId, string? question, List<string> options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId)
|
||||
{
|
||||
var poll = new PollMessage(id, chatId, senderId, question, null, isAnonymous, isMultiple, expiresAt, replyToId, forwardedFromId, DateTime.UtcNow, false);
|
||||
foreach (var opt in options)
|
||||
{
|
||||
poll.Options.Add(new PollOption { Text = opt });
|
||||
}
|
||||
return poll;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Knot.Contracts.Messaging.Domain;
|
||||
|
||||
public class PollOption
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public int VoteCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Contracts.Messaging.Domain;
|
||||
|
||||
public class PollVote
|
||||
{
|
||||
public Guid OptionId { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public DateTime VotedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Contracts.Messaging.Domain;
|
||||
|
||||
public class StoryMessage : Message
|
||||
{
|
||||
public override string Type => "story";
|
||||
public override string? Content { get; protected set; }
|
||||
public Guid? StoryId { get; private set; }
|
||||
public string? StoryMediaUrl { get; private set; }
|
||||
public string? StoryMediaType { get; private set; }
|
||||
|
||||
public StoryMessage() : base() { }
|
||||
|
||||
public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, DateTime createdAt)
|
||||
: base(id, chatId, senderId, null, null, createdAt, false)
|
||||
{
|
||||
StoryId = storyId;
|
||||
StoryMediaUrl = storyMediaUrl;
|
||||
StoryMediaType = storyMediaType;
|
||||
}
|
||||
|
||||
public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, string? content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||
: this(id, chatId, senderId, storyId, storyMediaUrl, storyMediaType, createdAt)
|
||||
{
|
||||
Content = content;
|
||||
ReplyToId = replyToId;
|
||||
ForwardedFromId = forwardedFromId;
|
||||
if (isImported) AddState(MessageState.IsImported);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Contracts.Messaging.Domain;
|
||||
|
||||
public class TextMessage : Message
|
||||
{
|
||||
public override string Type => "text";
|
||||
public override string? Content { get; protected set; }
|
||||
public string? Quote { get; protected set; }
|
||||
|
||||
public TextMessage() : base() { }
|
||||
|
||||
public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported = false)
|
||||
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) => Content = content;
|
||||
|
||||
public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, string? quote, Guid? forwardedFromId, DateTime createdAt, bool isImported = false)
|
||||
: this(id, chatId, senderId, content, replyToId, forwardedFromId, createdAt, isImported) => Quote = quote;
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
namespace Knot.Contracts.Profiles.Application.DTOs;
|
||||
namespace Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
public class UserProfileDto
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public Guid Id { get => UserId; set => UserId = value; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? About { get; set; }
|
||||
public string? Avatar { get; set; }
|
||||
public bool IsBot { get; set; }
|
||||
public DateTime? LastSeen { get; set; }
|
||||
public DateTime? Birthday { get; set; }
|
||||
public bool IsPremium { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Contracts.Profiles.Domain;
|
||||
@@ -12,4 +12,5 @@ public interface IProfileRepository
|
||||
Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken cancellationToken = default);
|
||||
Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken cancellationToken = default);
|
||||
Task<Result> DeleteAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using Knot.Shared.Kernel;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Contracts.Relations.Application.Contacts;
|
||||
|
||||
public record CheckBlockedStatusQuery(Guid UserId, List<Guid> CandidateIds) : IQuery<List<Guid>>;
|
||||
@@ -0,0 +1,7 @@
|
||||
using Knot.Shared.Kernel;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Contracts.Relations.Application.Contacts;
|
||||
|
||||
public record GetBlockedUserIdsQuery(Guid UserId) : IQuery<List<Guid>>;
|
||||
@@ -1,37 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Knot.Contracts.Settings.Abstractions;
|
||||
|
||||
public interface IStatisticsService
|
||||
{
|
||||
Task<DashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class DashboardStatsDto
|
||||
{
|
||||
public long StorageUsedBytes { get; set; }
|
||||
public long StorageLimitBytes { get; set; }
|
||||
public long OnlineUsers { get; set; }
|
||||
public long OfflineUsers { get; set; }
|
||||
public long TotalUsers { get; set; }
|
||||
public List<ActivityStatDto> ActivityTimeline { get; set; } = new();
|
||||
public List<TopUserDto> TopUsersByMessages { get; set; } = new();
|
||||
public List<TopUserDto> TopUsersByStorage { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ActivityStatDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public long Messages { get; set; }
|
||||
public long FilesSize { get; set; }
|
||||
}
|
||||
|
||||
public class TopUserDto
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public long Value { get; set; } // messages count or bytes
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Contracts.Settings.Application.DTOs;
|
||||
|
||||
@@ -53,6 +53,7 @@ public class MessagesConfig
|
||||
public class WebRtcConfig
|
||||
{
|
||||
public bool Enabled { get; set; } = false;
|
||||
public bool EnableVoiceCalls { get; set; } = true;
|
||||
public bool EnableVideoCalls { get; set; } = true;
|
||||
public bool EnableScreenSharing { get; set; } = true;
|
||||
public string TurnHost { get; set; } = string.Empty;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="Carter" Version="10.0.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
|
||||
+72
-32
@@ -1,31 +1,47 @@
|
||||
using Knot.Modules.Messaging;
|
||||
using Carter;
|
||||
using Knot.Shared.Infrastructure;
|
||||
using Knot.Modules.Auth;
|
||||
using Knot.Modules.Profiles;
|
||||
using Knot.Modules.Settings;
|
||||
using Knot.Modules.Admin;
|
||||
using Knot.Modules.Conversations;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Modules.Storage;
|
||||
using Knot.Modules.Stories;
|
||||
using Knot.Modules.Klipy;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Text;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Knot.Modules.Admin;
|
||||
using Knot.Modules.Admin.Presentation.Endpoints;
|
||||
using Knot.Modules.Auth;
|
||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||
using Knot.Modules.Auth.Presentation.Endpoints;
|
||||
using Knot.Modules.Conversations;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Knot.Modules.Conversations.Presentation.Endpoints;
|
||||
using Knot.Modules.Federation;
|
||||
using Knot.Modules.Federation.Presentation.Endpoints;
|
||||
using Knot.Modules.Klipy;
|
||||
using Knot.Modules.Klipy.Presentation.Endpoints;
|
||||
using Knot.Modules.Messaging;
|
||||
using Knot.Modules.Profiles;
|
||||
using Knot.Modules.Profiles.Presentation.Endpoints;
|
||||
using Knot.Modules.Relations;
|
||||
using Knot.Modules.Relations.Presentation.Endpoints;
|
||||
using Knot.Modules.Settings;
|
||||
using Knot.Modules.Settings.Presentation.Endpoints;
|
||||
using Knot.Modules.Storage;
|
||||
using Knot.Modules.Storage.Presentation.Endpoints;
|
||||
using Knot.Modules.Stories;
|
||||
using Knot.Modules.Stories.Presentation.Endpoints;
|
||||
using Knot.Modules.TelegramImport;
|
||||
using Knot.Modules.TelegramImport.Presentation.Endpoints;
|
||||
using Knot.Modules.WebRtc;
|
||||
using Knot.Modules.WebRtc.Presentation.Endpoints;
|
||||
using Knot.Shared.Infrastructure;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MediatR;
|
||||
|
||||
|
||||
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -33,7 +49,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// Маппинг стандартных переменных окружения в иерархию .NET
|
||||
var envMappings = new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"] ?? "Host=localhost;Database=knot;Username=postgres;Password=postgres",
|
||||
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"] ?? builder.Configuration.GetConnectionString("DefaultConnection") ?? "Host=localhost;Database=knot;Username=postgres;Password=postgres",
|
||||
["ConnectionStrings:MongoConnection"] = builder.Configuration["MONGO_CONNECTION"],
|
||||
["Jwt:Secret"] = builder.Configuration["JWT_SECRET"],
|
||||
["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"],
|
||||
@@ -60,10 +76,13 @@ builder.Services.AddSettingsModule(builder.Configuration);
|
||||
builder.Services.AddMessagingModule(builder.Configuration);
|
||||
builder.Services.AddConversationsModule(builder.Configuration);
|
||||
builder.Services.AddProfilesModule(builder.Configuration);
|
||||
builder.Services.AddRelationsModule(builder.Configuration);
|
||||
builder.Services.AddStorageModule(builder.Configuration);
|
||||
builder.Services.AddStoriesModule(builder.Configuration);
|
||||
builder.Services.AddKlipyModule();
|
||||
builder.Services.AddAdminModule();
|
||||
builder.Services.AddWebRtcModule();
|
||||
builder.Services.AddTelegramImportModule();
|
||||
builder.Services.AddSharedInfrastructure(builder.Configuration);
|
||||
|
||||
// CQRS / MediatR для команд в Host (например, AdminController)
|
||||
@@ -74,12 +93,14 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
||||
typeof(Knot.Modules.Messaging.DependencyInjection).Assembly,
|
||||
typeof(Knot.Modules.Conversations.DependencyInjection).Assembly,
|
||||
typeof(Knot.Modules.Stories.DependencyInjection).Assembly,
|
||||
typeof(Knot.Modules.Klipy.DependencyInjection).Assembly
|
||||
typeof(Knot.Modules.Klipy.DependencyInjection).Assembly,
|
||||
typeof(Knot.Modules.Relations.DependencyInjection).Assembly,
|
||||
typeof(Knot.Modules.WebRtc.DependencyInjection).Assembly,
|
||||
typeof(Knot.Modules.TelegramImport.DependencyInjection).Assembly,
|
||||
typeof(Knot.Modules.Auth.Infrastructure.Persistence.AuthDbContext).Assembly,
|
||||
typeof(Knot.Modules.Profiles.DependencyInjection).Assembly
|
||||
));
|
||||
|
||||
// Carter для вызова Minimal APIs (Endpoints)
|
||||
builder.Services.AddCarter();
|
||||
|
||||
// Настройка CORS
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
@@ -87,9 +108,9 @@ builder.Services.AddCors(options =>
|
||||
{
|
||||
var originsFromConfig = builder.Configuration["Cors:Origins"];
|
||||
var domain = builder.Configuration["DOMAIN"];
|
||||
|
||||
var origins = !string.IsNullOrEmpty(originsFromConfig)
|
||||
? originsFromConfig.Split(',')
|
||||
|
||||
var origins = !string.IsNullOrEmpty(originsFromConfig)
|
||||
? originsFromConfig.Split(',')
|
||||
: (!string.IsNullOrEmpty(domain) ? new[] { $"https://{domain}" } : new[] { "*" });
|
||||
|
||||
var corsBuilder = policy.WithOrigins(origins)
|
||||
@@ -111,7 +132,7 @@ builder.Services.AddCors(options =>
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
// Настройка маршрутизации
|
||||
builder.Services.AddRouting(options =>
|
||||
builder.Services.AddRouting(options =>
|
||||
{
|
||||
options.LowercaseUrls = true;
|
||||
options.LowercaseQueryStrings = true;
|
||||
@@ -175,7 +196,7 @@ using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var identityDb = scope.ServiceProvider.GetRequiredService<AuthDbContext>();
|
||||
await identityDb.Database.MigrateAsync();
|
||||
|
||||
|
||||
var chatsDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Conversations.Infrastructure.Persistence.ChatsDbContext>();
|
||||
await chatsDb.Database.MigrateAsync();
|
||||
|
||||
@@ -185,6 +206,9 @@ using (var scope = app.Services.CreateScope())
|
||||
var storiesDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Stories.Infrastructure.Database.StoriesDbContext>();
|
||||
await storiesDb.Database.MigrateAsync();
|
||||
|
||||
var relationsDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Relations.Infrastructure.Persistence.RelationsDbContext>();
|
||||
await relationsDb.Database.MigrateAsync();
|
||||
|
||||
// Set Encryption Service for MongoDB serializers
|
||||
var encryptionService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Security.IEncryptionService>();
|
||||
Knot.Modules.Messaging.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService;
|
||||
@@ -195,6 +219,10 @@ using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
concreteSettings.Initialize();
|
||||
}
|
||||
|
||||
// Sync user replicas for Relations module on startup
|
||||
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
await mediator.Send(new Knot.Modules.Relations.Application.Contacts.SyncReplicasCommand());
|
||||
}
|
||||
|
||||
// Настройка конвейера запросов
|
||||
@@ -216,8 +244,20 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Добавляем контроллеры и Carter (Minimal APIs)
|
||||
app.MapCarter();
|
||||
// Регистрация эндпоинтов
|
||||
app.MapAuthEndpoints();
|
||||
app.MapStoriesEndpoints();
|
||||
app.MapContactsEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapProfilesEndpoints();
|
||||
app.MapFederationEndpoints();
|
||||
app.MapKlipyEndpoints();
|
||||
app.MapChatsEndpoints();
|
||||
app.MapMessagesEndpoints();
|
||||
app.MapAdminEndpoints();
|
||||
app.MapFilesEndpoints();
|
||||
app.MapWebRtcEndpoints();
|
||||
app.MapTelegramImportEndpoints();
|
||||
|
||||
// Добавляем SignalR хабы
|
||||
app.MapHub<ChatHub>("/hubs/chat");
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"Secret": "knot_super_secret_key_1234567890_knot",
|
||||
"Issuer": "Knot",
|
||||
"Audience": "KnotUsers",
|
||||
"ExpiryInMinutes": 1440
|
||||
"ExpiryInMinutes": 1440,
|
||||
"RefreshExpiryInDays": 30
|
||||
},
|
||||
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ using Knot.Contracts.Auth.Infrastructure.Persistence;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Storage.Abstractions;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using Knot.Contracts.Stories.Infrastructure.Persistence;
|
||||
using Knot.Modules.Admin.Application.Admin.DTOs;
|
||||
using MediatR;
|
||||
@@ -51,7 +51,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
|
||||
var orphanMessages = await _messageQueryService.GetOrphanedMessagesAsync(activeChatIds, cancellationToken);
|
||||
|
||||
var keptMessages = allMessages
|
||||
.Where(m => !orphanMessages.Any(om => om.Id == m.Id))
|
||||
.Where(m => !orphanMessages.Any(om => om.Id == m.Id) && !m.IsDeleted)
|
||||
.ToList();
|
||||
|
||||
var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using BCrypt.Net;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Knot.Modules.Admin.Application.Admin.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Admin.Application.Admin.Commands.CreateUser;
|
||||
|
||||
public record CreateUserCommand(
|
||||
string Username,
|
||||
string Password,
|
||||
string DisplayName,
|
||||
string? Email = null,
|
||||
string? Bio = null
|
||||
) : ICommand<AdminUserDto>;
|
||||
|
||||
internal sealed class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, AdminUserDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IAuthUnitOfWork _unitOfWork;
|
||||
|
||||
public CreateUserCommandHandler(IUserRepository userRepository, IAuthUnitOfWork unitOfWork)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result<AdminUserDto>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Проверка уникальности username
|
||||
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
||||
{
|
||||
return Result.Failure<AdminUserDto>(new Error("Admin.CreateUser.UsernameNotUnique", "Username is already taken"));
|
||||
}
|
||||
|
||||
// Хеширование пароля
|
||||
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
||||
|
||||
// Создание контракта пользователя
|
||||
var userContract = new UserContract
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = request.Username,
|
||||
PasswordHash = passwordHash,
|
||||
DisplayName = request.DisplayName,
|
||||
Email = request.Email,
|
||||
Bio = request.Bio,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsBanned = false,
|
||||
IsOnline = false
|
||||
};
|
||||
|
||||
_unitOfWork.Add(userContract);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(new AdminUserDto(
|
||||
userContract.Id,
|
||||
userContract.Username,
|
||||
userContract.DisplayName,
|
||||
userContract.Email,
|
||||
userContract.Avatar,
|
||||
userContract.CreatedAt,
|
||||
userContract.IsOnline,
|
||||
userContract.LastSeen ?? DateTime.UtcNow,
|
||||
userContract.IsBanned));
|
||||
}
|
||||
}
|
||||
+35
-14
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -8,6 +8,7 @@ using Knot.Contracts.Auth.Infrastructure.Persistence;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using Knot.Contracts.Stories.Infrastructure.Persistence;
|
||||
using Knot.Modules.Admin.Application.Admin.DTOs;
|
||||
using MediatR;
|
||||
@@ -23,17 +24,20 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
|
||||
private readonly IAuthDbContext _authDbContext;
|
||||
private readonly IChatsDbContext _chatsDbContext;
|
||||
private readonly IStoryCollection _storyCollection;
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
|
||||
public CleanDryRunQueryHandler(
|
||||
Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService messageService,
|
||||
IAuthDbContext authDbContext,
|
||||
IChatsDbContext chatsDbContext,
|
||||
IStoryCollection storyCollection)
|
||||
IStoryCollection storyCollection,
|
||||
IFileStorageService fileStorage)
|
||||
{
|
||||
_messageService = messageService;
|
||||
_authDbContext = authDbContext;
|
||||
_chatsDbContext = chatsDbContext;
|
||||
_storyCollection = storyCollection;
|
||||
_fileStorage = fileStorage;
|
||||
}
|
||||
|
||||
public async Task<Result<CleanDryRunResult>> Handle(CleanDryRunQuery request, CancellationToken ct)
|
||||
@@ -46,25 +50,42 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
|
||||
var orphanedMediaCount = orphanedMessages.Count(m => m.MediaUrl != null);
|
||||
var orphanedMessageCount = orphanedMessages.Count;
|
||||
|
||||
var validIds = new HashSet<string>();
|
||||
foreach (var msg in orphanedMessages.Where(m => m.MediaUrl != null))
|
||||
{
|
||||
var parts = msg.MediaUrl.Split('/');
|
||||
var fileId = parts.LastOrDefault();
|
||||
if (!string.IsNullOrEmpty(fileId))
|
||||
{
|
||||
validIds.Add(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
var allMessages = await _messageService.GetAllMessagesAsync(ct);
|
||||
var keptMessages = allMessages
|
||||
.Where(m => !orphanedMessages.Any(om => om.Id == m.Id) && !m.IsDeleted)
|
||||
.ToList();
|
||||
var allUsers = await _authDbContext.Users.ToListAsync(ct);
|
||||
var stories = await _storyCollection.GetAllAsync(ct);
|
||||
|
||||
var validUrls = new HashSet<string>();
|
||||
|
||||
var activeMessageUrls = keptMessages.Where(m => m.Media != null).SelectMany(m => m.Media!).Select(x => x.Url).Where(u => !string.IsNullOrEmpty(u));
|
||||
var activeChatUrls = _chatsDbContext.Chats.Select(c => c.Avatar).Where(u => !string.IsNullOrEmpty(u));
|
||||
var activeUserUrls = allUsers.Select(u => u.Avatar).Where(u => !string.IsNullOrEmpty(u));
|
||||
var activeStoryUrls = stories.Select(s => s.MediaUrl).Where(u => !string.IsNullOrEmpty(u));
|
||||
|
||||
foreach (var u in activeMessageUrls) validUrls.Add(u!);
|
||||
foreach (var u in activeChatUrls) validUrls.Add(u!);
|
||||
foreach (var u in activeUserUrls) validUrls.Add(u!);
|
||||
foreach (var u in activeStoryUrls) validUrls.Add(u!);
|
||||
|
||||
var validFileIds = validUrls
|
||||
.Where(u => u.Contains("/api/files/"))
|
||||
.Select(u => u.Split('/').Last())
|
||||
.ToHashSet();
|
||||
|
||||
var allMinioFiles = await _fileStorage.ListFilesAsync();
|
||||
long orphanedFileSize = allMinioFiles
|
||||
.Where(f => !validFileIds.Contains(f.FileId))
|
||||
.Sum(f => f.Size);
|
||||
|
||||
var expiredStoriesCount = stories.Count(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow);
|
||||
var expiredStoriesSize = stories.Where(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow).Sum(s => s.MediaUrl?.Length ?? 0);
|
||||
|
||||
return Result.Success(new CleanDryRunResult(
|
||||
orphanedMessageCount,
|
||||
orphanedMediaCount,
|
||||
0,
|
||||
orphanedFileSize,
|
||||
expiredStoriesCount,
|
||||
expiredStoriesSize
|
||||
));
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Admin.Abstractions;
|
||||
using Knot.Contracts.Settings.Abstractions;
|
||||
using Knot.Modules.Admin.Application.Admin.DTOs;
|
||||
using Knot.Shared.Kernel.Services;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Admin.Application.Admin.Queries;
|
||||
|
||||
+3
-5
@@ -1,11 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Admin.Abstractions;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Abstractions;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Modules.Admin.Application.Admin.DTOs;
|
||||
using MediatR;
|
||||
@@ -18,12 +16,12 @@ internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQ
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService _statsService;
|
||||
private readonly Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService _statusService;
|
||||
private readonly IUserStatusService _statusService;
|
||||
|
||||
public GetUserDetailsQueryHandler(
|
||||
IUserRepository userRepository,
|
||||
Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService statsService,
|
||||
Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService statusService)
|
||||
IUserStatusService statusService)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_statsService = statsService;
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Admin.Abstractions;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Abstractions;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Modules.Admin.Application.Admin.DTOs;
|
||||
using MediatR;
|
||||
@@ -18,12 +18,12 @@ internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery,
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService _statsService;
|
||||
private readonly Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService _statusService;
|
||||
private readonly IUserStatusService _statusService;
|
||||
|
||||
public SearchUsersQueryHandler(
|
||||
IUserRepository userRepository,
|
||||
Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService statsService,
|
||||
Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService statusService)
|
||||
IUserStatusService statusService)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_statsService = statsService;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -18,8 +18,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Carter" Version="10.0.0" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,7 +1,9 @@
|
||||
using Carter;
|
||||
using System.Text.Json.Serialization;
|
||||
using Knot.Contracts.Conversations.Abstractions;
|
||||
using Knot.Contracts.Settings.Application.Abstractions;
|
||||
using Knot.Contracts.Settings.Application.DTOs;
|
||||
using Knot.Modules.Admin.Application.Admin.Commands;
|
||||
using Knot.Modules.Admin.Application.Admin.Commands.CreateUser;
|
||||
using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
|
||||
using Knot.Modules.Admin.Application.Admin.Queries;
|
||||
using MediatR;
|
||||
@@ -10,14 +12,16 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Knot.Host.Presentation.Endpoints;
|
||||
namespace Knot.Modules.Admin.Presentation.Endpoints;
|
||||
|
||||
public record KlipyTestDto(string ApiKey, string AppName);
|
||||
public record KlipyTestDto(
|
||||
[property: JsonPropertyName("apiKey")] string ApiKey,
|
||||
[property: JsonPropertyName("appName")] string AppName);
|
||||
public record ResetPasswordRequest(string NewPassword);
|
||||
|
||||
public sealed class AdminEndpoints : ICarterModule
|
||||
public static class AdminEndpoints
|
||||
{
|
||||
public void AddRoutes(IEndpointRouteBuilder app)
|
||||
public static void MapAdminEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("api/admin"); // Middleware handles auth
|
||||
|
||||
@@ -35,7 +39,16 @@ public sealed class AdminEndpoints : ICarterModule
|
||||
|
||||
group.MapPost("settings/test-klipy", async ([FromBody] KlipyTestDto dto, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
Console.WriteLine($"[Admin] TestKlipy received: ApiKey={(string.IsNullOrEmpty(dto.ApiKey) ? "EMPTY" : "present")}, AppName={(string.IsNullOrEmpty(dto.AppName) ? "EMPTY" : dto.AppName)}");
|
||||
|
||||
if (string.IsNullOrEmpty(dto.ApiKey) || string.IsNullOrEmpty(dto.AppName))
|
||||
{
|
||||
Console.WriteLine($"[Admin] TestKlipy: Missing required fields - ApiKey={dto?.ApiKey}, AppName={dto?.AppName}");
|
||||
return Results.BadRequest(new { error = "ApiKey and AppName are required" });
|
||||
}
|
||||
|
||||
var result = await sender.Send(new TestKlipyConnectionCommand(dto.ApiKey, dto.AppName), ct);
|
||||
Console.WriteLine($"[Admin] TestKlipy result: IsSuccess={result.IsSuccess}, Error={result.Error?.Description}");
|
||||
return result.IsSuccess ? Results.Ok(new { success = true }) : Results.BadRequest(new { error = result.Error.Description });
|
||||
});
|
||||
|
||||
@@ -45,7 +58,13 @@ public sealed class AdminEndpoints : ICarterModule
|
||||
return Results.Ok(result.Value);
|
||||
});
|
||||
|
||||
group.MapPost("users/{userId:guid}/reset-password", async (Guid userId, [FromBody] ResetPasswordRequest dto, ISender sender, CancellationToken ct) =>
|
||||
group.MapPost("users", async ([FromBody] CreateUserCommand command, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(command, ct);
|
||||
return result.IsSuccess ? Results.Created($"/api/admin/users/{result.Value.Id}", result.Value) : Results.BadRequest(new { error = result.Error.Description });
|
||||
});
|
||||
|
||||
group.MapPost("users/{userId:guid}/reset-password", async ([FromRoute] Guid userId, [FromBody] ResetPasswordRequest dto, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
|
||||
if (result.IsFailure)
|
||||
@@ -56,34 +75,51 @@ public sealed class AdminEndpoints : ICarterModule
|
||||
return Results.Ok(result.Value);
|
||||
});
|
||||
|
||||
group.MapPost("users/{userId:guid}/ban", async (Guid userId, ISender sender, CancellationToken ct) =>
|
||||
group.MapPost("users/{userId:guid}/ban", async ([FromRoute] Guid userId, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new BanUserCommand(userId), ct);
|
||||
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
|
||||
});
|
||||
|
||||
group.MapPost("users/{userId:guid}/unban", async (Guid userId, ISender sender, CancellationToken ct) =>
|
||||
group.MapPost("users/{userId:guid}/unban", async ([FromRoute] Guid userId, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new UnbanUserCommand(userId), ct);
|
||||
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
|
||||
});
|
||||
|
||||
group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) =>
|
||||
group.MapGet("users", async (ISender sender, [FromQuery] string? query, CancellationToken ct = default) =>
|
||||
{
|
||||
var result = await sender.Send(new SearchUsersQuery(query), ct);
|
||||
if (!result.IsSuccess)
|
||||
// При пустом или отсутствующем query возвращаем всех пользователей
|
||||
var searchQuery = string.IsNullOrWhiteSpace(query) ? "" : query;
|
||||
try
|
||||
{
|
||||
return Results.BadRequest(new { error = result.Error.Description });
|
||||
var result = await sender.Send(new SearchUsersQuery(searchQuery), ct);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
Console.WriteLine($"[Admin] SearchUsers error: {result.Error.Code} - {result.Error.Description}");
|
||||
return Results.BadRequest(new { error = result.Error.Description });
|
||||
}
|
||||
return Results.Ok(result.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Admin] SearchUsers exception: {ex}");
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
return Results.Ok(result.Value);
|
||||
});
|
||||
|
||||
group.MapGet("users/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
|
||||
group.MapGet("users/{id:guid}", async ([FromRoute] Guid id, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetUserDetailsQuery(id), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound("User not found");
|
||||
});
|
||||
|
||||
group.MapDelete("users/{userId:guid}", async ([FromRoute] Guid userId, IUserDeleterService userDeleter, CancellationToken ct) =>
|
||||
{
|
||||
var result = await userDeleter.DeleteUserAsync(userId, ct);
|
||||
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
|
||||
});
|
||||
|
||||
group.MapGet("clean/dry-run", async (ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new CleanDryRunQuery(), ct);
|
||||
@@ -95,6 +131,17 @@ public sealed class AdminEndpoints : ICarterModule
|
||||
return Results.Ok(result.Value);
|
||||
});
|
||||
|
||||
group.MapPost("clean/run", async (ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new CleanRunCommand(), ct);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
Console.WriteLine($"[Admin] Cleanup Run Error: {result.Error.Description}");
|
||||
return Results.BadRequest(new { error = result.Error.Description });
|
||||
}
|
||||
return Results.Ok(result.Value);
|
||||
});
|
||||
|
||||
group.MapGet("timezones", () =>
|
||||
{
|
||||
// Получаем все системные часовые пояса и формируем удобный для фронтенда формат
|
||||
|
||||
@@ -5,5 +5,8 @@ namespace Knot.Modules.Auth.Application.Abstractions;
|
||||
public interface IJwtTokenProvider
|
||||
{
|
||||
string Generate(User user);
|
||||
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||
string GenerateRefreshToken();
|
||||
DateTime GetRefreshTokenExpiry();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.GetMe;
|
||||
|
||||
@@ -9,10 +11,12 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
|
||||
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IJwtTokenProvider _tokenProvider;
|
||||
|
||||
public GetMeQueryHandler(IUserRepository userRepository)
|
||||
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
|
||||
@@ -23,13 +27,23 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
|
||||
}
|
||||
|
||||
// Check if access token needs to be refreshed (less than 1 hour remaining)
|
||||
string? newAccessToken = null;
|
||||
|
||||
// We can't directly check the current token's expiry here, but we can
|
||||
// always issue a new token if the user is authenticated
|
||||
// For now, let's issue a new token on every request (simplified approach)
|
||||
// A better approach would be to parse the incoming token and check expiry
|
||||
newAccessToken = _tokenProvider.Generate(user);
|
||||
|
||||
var response = new AuthResponseDto
|
||||
{
|
||||
AccessToken = string.Empty,
|
||||
AccessToken = newAccessToken,
|
||||
RefreshToken = string.Empty,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName
|
||||
DisplayName = user.DisplayName,
|
||||
Avatar = user.Avatar
|
||||
};
|
||||
|
||||
return Result.Success(response);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users;
|
||||
|
||||
internal sealed class GetUsersExistenceQueryHandler : IQueryHandler<GetUsersExistenceQuery, List<Guid>>
|
||||
{
|
||||
private readonly IAuthDbContext _context;
|
||||
|
||||
public GetUsersExistenceQueryHandler(IAuthDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Result<List<Guid>>> Handle(GetUsersExistenceQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var existingIds = await _context.Set<Knot.Modules.Auth.Domain.User>()
|
||||
.Where(u => request.UserIds.Contains(u.Id))
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Success(existingIds);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
using BCrypt.Net;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.Login;
|
||||
|
||||
/// <summary>
|
||||
/// ������� ��� ����� ������������. ���������� AuthResponseDto.
|
||||
/// </summary>
|
||||
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
|
||||
|
||||
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
|
||||
@@ -32,14 +28,21 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
|
||||
}
|
||||
|
||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||
string refreshToken = _tokenProvider.GenerateRefreshToken();
|
||||
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||
|
||||
// Save refresh token to database
|
||||
user.SetRefreshToken(refreshToken, refreshExpiry);
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
return Result.Success(new AuthResponseDto
|
||||
{
|
||||
AccessToken = token,
|
||||
RefreshToken = string.Empty,
|
||||
RefreshToken = refreshToken,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||
|
||||
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;
|
||||
@@ -0,0 +1,65 @@
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||
|
||||
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IJwtTokenProvider _tokenProvider;
|
||||
|
||||
public RefreshTokenCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
IJwtTokenProvider tokenProvider)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(
|
||||
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
|
||||
}
|
||||
|
||||
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(
|
||||
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
|
||||
}
|
||||
|
||||
// Check if refresh token has expired
|
||||
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
|
||||
{
|
||||
// Clear expired refresh token
|
||||
user.SetRefreshToken(null, null);
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
return Result.Failure<AuthResponseDto>(
|
||||
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
|
||||
}
|
||||
|
||||
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||
|
||||
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
|
||||
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
return Result.Success(new AuthResponseDto
|
||||
{
|
||||
AccessToken = newAccessToken,
|
||||
RefreshToken = newRefreshToken,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName,
|
||||
Avatar = user.Avatar
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using BCrypt.Net;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Modules.Auth.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Contracts.Settings.Application.Abstractions;
|
||||
using Knot.Contracts.Settings.Application.DTOs;
|
||||
using BCrypt.Net;
|
||||
using Knot.Modules.Auth.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.Register;
|
||||
|
||||
/// <summary>
|
||||
/// Êîìàíäà äëÿ ðåãèñòðàöèè íîâîãî ïîëüçîâàòåëÿ.
|
||||
/// </summary>
|
||||
public sealed record RegisterUserCommand(
|
||||
string Username,
|
||||
string Password,
|
||||
@@ -19,9 +15,6 @@ public sealed record RegisterUserCommand(
|
||||
string? Email,
|
||||
string? Bio) : ICommand<AuthResponseDto>;
|
||||
|
||||
/// <summary>
|
||||
/// Îáðàáîò÷èê êîìàíäû ðåãèñòðàöèè.
|
||||
/// </summary>
|
||||
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
@@ -48,16 +41,13 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
|
||||
}
|
||||
|
||||
// 1. Ïðîâåðêà óíèêàëüíîñòè username
|
||||
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
|
||||
}
|
||||
|
||||
// 2. Õåøèðîâàíèå ïàðîëÿ
|
||||
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
||||
|
||||
// 3. Ñîçäàíèå ñóùíîñòè
|
||||
var user = User.Create(
|
||||
request.Username,
|
||||
passwordHash,
|
||||
@@ -65,21 +55,33 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
||||
request.Email,
|
||||
request.Bio);
|
||||
|
||||
// 4. Ñîõðàíåíèå - èñïîëüçóåì ìåòîä ñ Domain User
|
||||
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||
repoWithDomainUserAdd?.Add(user);
|
||||
|
||||
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||
repoImpl?.Add(user);
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||
// Get the saved user as contract
|
||||
var userContract = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
|
||||
if (userContract == null)
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationFailed);
|
||||
}
|
||||
|
||||
string token = _tokenProvider.Generate(userContract.Id, userContract.Username, userContract.DisplayName, userContract.Avatar);
|
||||
string refreshToken = _tokenProvider.GenerateRefreshToken();
|
||||
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||
|
||||
userContract.SetRefreshToken(refreshToken, refreshExpiry);
|
||||
await _userRepository.UpdateAsync(userContract, cancellationToken);
|
||||
|
||||
return Result.Success(new AuthResponseDto
|
||||
{
|
||||
AccessToken = token,
|
||||
RefreshToken = string.Empty,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName
|
||||
RefreshToken = refreshToken,
|
||||
UserId = userContract.Id,
|
||||
Username = userContract.Username,
|
||||
DisplayName = userContract.DisplayName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAuthUnitOfWork>(sp => sp.GetRequiredService<AuthDbContext>());
|
||||
services.AddScoped<Knot.Contracts.Auth.Application.Abstractions.IAuthDbContext>(sp => sp.GetRequiredService<AuthDbContext>());
|
||||
services.AddScoped<Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext>(sp => sp.GetRequiredService<AuthDbContext>());
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<Knot.Contracts.Auth.Domain.IUserRepository, UserRepository>();
|
||||
services.AddScoped<IJwtTokenProvider, JwtTokenProvider>();
|
||||
services.AddScoped<IUserDisplayNameProvider, Knot.Modules.Auth.Infrastructure.Services.UserDisplayNameProvider>();
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using Knot.Modules.Auth.Domain;
|
||||
|
||||
namespace Knot.Modules.Auth.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Интерфейс репозитория для работы с пользователями.
|
||||
/// </summary>
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<List<User>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default);
|
||||
Task<User?> GetByUsernameAsync(string username, CancellationToken cancellationToken = default);
|
||||
Task<bool> IsUsernameUniqueAsync(string username, CancellationToken cancellationToken = default);
|
||||
Task<List<User>> SearchUsersAsync(string query, CancellationToken cancellationToken = default);
|
||||
void Add(User user);
|
||||
void Update(User user);
|
||||
void Remove(User user);
|
||||
}
|
||||
|
||||
@@ -26,10 +26,17 @@ public sealed class User : AggregateRoot<Guid>
|
||||
public bool IsBanned { get; private set; }
|
||||
public string? PhoneNumber { get; private set; }
|
||||
public string? RefreshToken { get; private set; }
|
||||
public DateTime? RefreshTokenExpiry { get; private set; }
|
||||
public DateTime? BannedUntil { get; private set; }
|
||||
|
||||
public void Ban() => IsBanned = true;
|
||||
public void Unban() => IsBanned = false;
|
||||
public void Ban() {
|
||||
IsBanned = true;
|
||||
RaiseDomainEvent(new UserBannedDomainEvent(Id, true));
|
||||
}
|
||||
public void Unban() {
|
||||
IsBanned = false;
|
||||
RaiseDomainEvent(new UserBannedDomainEvent(Id, false));
|
||||
}
|
||||
|
||||
public void SetOnline(bool isOnline, DateTime? lastSeen = null)
|
||||
{
|
||||
@@ -42,9 +49,10 @@ public sealed class User : AggregateRoot<Guid>
|
||||
PhoneNumber = phoneNumber;
|
||||
}
|
||||
|
||||
public void SetRefreshToken(string? refreshToken)
|
||||
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||
{
|
||||
RefreshToken = refreshToken;
|
||||
RefreshTokenExpiry = expiry;
|
||||
}
|
||||
|
||||
public void SetBannedUntil(DateTime? bannedUntil)
|
||||
@@ -66,10 +74,15 @@ public sealed class User : AggregateRoot<Guid>
|
||||
Username = contract.Username;
|
||||
DisplayName = contract.DisplayName;
|
||||
PhoneNumber = contract.PhoneNumber;
|
||||
Bio = contract.Bio;
|
||||
Avatar = contract.Avatar;
|
||||
Birthday = contract.Birthday;
|
||||
IsBanned = contract.IsBanned;
|
||||
BannedUntil = contract.BannedUntil;
|
||||
SetOnline(contract.IsOnline, contract.LastSeen);
|
||||
UserDomain = contract.Domain;
|
||||
RefreshToken = contract.RefreshToken;
|
||||
RefreshTokenExpiry = contract.RefreshTokenExpiry;
|
||||
}
|
||||
|
||||
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
|
||||
@@ -172,7 +185,9 @@ public sealed class User : AggregateRoot<Guid>
|
||||
IsOnline = IsOnline,
|
||||
IsExternal = IsExternal,
|
||||
Domain = _domain,
|
||||
LastSeen = LastSeen
|
||||
LastSeen = LastSeen,
|
||||
RefreshToken = RefreshToken,
|
||||
RefreshTokenExpiry = RefreshTokenExpiry
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Глобальные алиасы для модуля Auth
|
||||
// Используем контракты вместо дублирующихся интерфейсов из модуля
|
||||
global using AuthErrors = Knot.Contracts.Auth.Application.Auth.DTOs.AuthErrors;
|
||||
global using IUserRepository = Knot.Contracts.Auth.Domain.IUserRepository;
|
||||
@@ -50,6 +50,12 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
|
||||
return Convert.ToBase64String(randomBytes);
|
||||
}
|
||||
|
||||
public DateTime GetRefreshTokenExpiry()
|
||||
{
|
||||
var expiryInDays = int.Parse(_configuration["Jwt:RefreshExpiryInDays"] ?? "30");
|
||||
return DateTime.UtcNow.AddDays(expiryInDays);
|
||||
}
|
||||
|
||||
public string Generate(Guid userId, string username, string displayName, string? avatar)
|
||||
{
|
||||
var claims = new Claim[]
|
||||
|
||||
@@ -65,6 +65,7 @@ internal sealed class UserRepository : IUserRepository
|
||||
{
|
||||
domainUser.UpdateFromContract(user);
|
||||
_context.Users.Update(domainUser);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Auth.Infrastructure.Services;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
|
||||
<PackageReference Include="Carter" Version="10.0.0" />
|
||||
<PackageReference Include="MediatR" Version="12.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Knot.Modules.Auth.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.Auth.Migrations
|
||||
{
|
||||
[DbContext(typeof(AuthDbContext))]
|
||||
[Migration("20260407181656_AddUserInfoFields")]
|
||||
partial class AddUserInfoFields
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("identity")
|
||||
.HasAnnotation("ProductVersion", "10.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Domain")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("HideStatus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HideStoryViews")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsExternal")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsOnline")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastSeen")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserDomain")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users", "identity");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Auth.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserInfoFields : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='identity' AND table_name='Users' AND column_name='BannedUntil') THEN
|
||||
ALTER TABLE identity.""Users"" ADD ""BannedUntil"" timestamp with time zone;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='identity' AND table_name='Users' AND column_name='PhoneNumber') THEN
|
||||
ALTER TABLE identity.""Users"" ADD ""PhoneNumber"" text;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='identity' AND table_name='Users' AND column_name='RefreshToken') THEN
|
||||
ALTER TABLE identity.""Users"" ADD ""RefreshToken"" text;
|
||||
END IF;
|
||||
END $$;");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(name: "BannedUntil", schema: "identity", table: "Users");
|
||||
migrationBuilder.DropColumn(name: "PhoneNumber", schema: "identity", table: "Users");
|
||||
migrationBuilder.DropColumn(name: "RefreshToken", schema: "identity", table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Auth.Migrations
|
||||
{
|
||||
[DbContext(typeof(AuthDbContext))]
|
||||
[Migration("20270328000000_AddUserUserDomainField")]
|
||||
partial class AddUserUserDomainField
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("identity")
|
||||
.HasAnnotation("ProductVersion", "10.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Domain")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("HideStatus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HideStoryViews")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsExternal")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsOnline")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastSeen")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserDomain")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users", "identity");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Auth.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserUserDomainField : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "UserDomain",
|
||||
schema: "identity",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UserDomain",
|
||||
schema: "identity",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Auth.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(AuthDbContext))]
|
||||
[Migration("20270419220000_AddRefreshTokenExpiry")]
|
||||
partial class AddRefreshTokenExpiry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
|
||||
.HasAnnotation("Relational:DefaultSchema", "identity");
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Domain")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("HideStatus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HideStoryViews")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsExternal")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsOnline")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastSeen")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpiry")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users", "identity");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Auth.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRefreshTokenExpiry : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "RefreshTokenExpiry",
|
||||
schema: "identity",
|
||||
table: "Users",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RefreshTokenExpiry",
|
||||
schema: "identity",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -23,7 +23,7 @@ namespace Knot.Modules.Auth.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Knot.Contracts.Auth.Domain.User", b =>
|
||||
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -32,6 +32,9 @@ namespace Knot.Modules.Auth.Migrations
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -57,10 +60,10 @@ namespace Knot.Modules.Auth.Migrations
|
||||
b.Property<bool>("HideStoryViews")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsExternal")
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
b.Property<bool>("IsExternal")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsOnline")
|
||||
@@ -73,6 +76,15 @@ namespace Knot.Modules.Auth.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserDomain")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
using Carter;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Auth.Application.Users.Login;
|
||||
using Knot.Modules.Auth.Application.Users.Register;
|
||||
using Knot.Modules.Auth.Application.Users.GetMe;
|
||||
using Knot.Modules.Auth.Application.Users.Login;
|
||||
using Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||
using Knot.Modules.Auth.Application.Users.Register;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Knot.Modules.Auth.Presentation.Endpoints;
|
||||
|
||||
public sealed class AuthEndpoints : ICarterModule
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public void AddRoutes(IEndpointRouteBuilder app)
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("api/auth");
|
||||
|
||||
@@ -29,6 +29,12 @@ public sealed class AuthEndpoints : ICarterModule
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
||||
});
|
||||
|
||||
group.MapPost("refresh", async ([FromBody] RefreshTokenCommand command, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(command, ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
||||
});
|
||||
|
||||
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Work специфичный для модуля Chats.
|
||||
/// </summary>
|
||||
public interface IChatsUnitOfWork : IUnitOfWork
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Abstractions;
|
||||
|
||||
public interface IUserStatusService
|
||||
{
|
||||
bool IsUserOnline(string userId);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
using SixLabors.ImageSharp;
|
||||
|
||||
@@ -2,8 +2,8 @@ using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Chats.Create;
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
@@ -63,6 +63,9 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
||||
}
|
||||
}
|
||||
|
||||
var pinnedMessages = await _messageRepository.GetPinnedMessagesAsync(chat.Id, cancellationToken);
|
||||
foreach (var pm in pinnedMessages) userIdsToFetch.Add(pm.SenderId);
|
||||
|
||||
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||
|
||||
var members = new List<ChatMemberDto>();
|
||||
@@ -88,51 +91,19 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
||||
var messagesList = new List<ChatMessageDto>();
|
||||
if (latestMessage != null)
|
||||
{
|
||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
||||
messagesList.Add(MessageMapper.MapToDto(
|
||||
latestMessage,
|
||||
usersInfo,
|
||||
latestReactions,
|
||||
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
|
||||
}
|
||||
|
||||
var reactionsWithUser = new List<ReactionDto>();
|
||||
foreach (var reaction in latestReactions)
|
||||
{
|
||||
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 readByList = chat.Members
|
||||
.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId)
|
||||
.Select(m => new ReadByDto(m.UserId))
|
||||
.ToList();
|
||||
|
||||
messagesList.Add(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.SequenceId,
|
||||
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(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
readByList
|
||||
var pinnedDtoList = new List<PinnedMessageDto>();
|
||||
foreach (var pm in pinnedMessages)
|
||||
{
|
||||
pinnedDtoList.Add(new PinnedMessageDto(
|
||||
pm.Id,
|
||||
MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
|
||||
));
|
||||
}
|
||||
|
||||
@@ -148,6 +119,7 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
||||
chat.CreatedAt,
|
||||
members,
|
||||
messagesList,
|
||||
pinnedDtoList,
|
||||
unreadCount
|
||||
);
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
@@ -59,6 +59,9 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
}
|
||||
}
|
||||
|
||||
var pinnedMessages = await _messageRepository.GetPinnedMessagesAsync(chat.Id, cancellationToken);
|
||||
foreach (var pm in pinnedMessages) userIdsToFetch.Add(pm.SenderId);
|
||||
|
||||
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||
|
||||
var members = new List<ChatMemberDto>();
|
||||
@@ -85,46 +88,19 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
|
||||
if (latestMessage != null)
|
||||
{
|
||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
||||
messagesList.Add(MessageMapper.MapToDto(
|
||||
latestMessage,
|
||||
usersInfo,
|
||||
latestReactions,
|
||||
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
|
||||
}
|
||||
|
||||
var reactionsWithUser = new List<ReactionDto>();
|
||||
foreach (var reaction in latestReactions)
|
||||
{
|
||||
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)
|
||||
));
|
||||
}
|
||||
|
||||
messagesList.Add(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.SequenceId,
|
||||
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(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => new ReadByDto(m.UserId)).ToList()
|
||||
var pinnedDtoList = new List<PinnedMessageDto>();
|
||||
foreach (var pm in pinnedMessages)
|
||||
{
|
||||
pinnedDtoList.Add(new PinnedMessageDto(
|
||||
pm.Id,
|
||||
MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
|
||||
));
|
||||
}
|
||||
|
||||
@@ -140,7 +116,10 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
chat.CreatedAt,
|
||||
members,
|
||||
messagesList,
|
||||
unreadCount
|
||||
pinnedDtoList,
|
||||
unreadCount,
|
||||
chat.IsImporting,
|
||||
chat.ImportJobId
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
|
||||
|
||||
|
||||
+76
-7
@@ -1,12 +1,15 @@
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
|
||||
|
||||
@@ -15,12 +18,24 @@ public record LeaveOrDeleteChatCommand(Guid ChatId, Guid UserId) : ICommand<Succ
|
||||
internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrDeleteChatCommand, SuccessResponse>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
|
||||
public LeaveOrDeleteChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
public LeaveOrDeleteChatCommandHandler(
|
||||
IChatRepository chatRepository,
|
||||
|
||||
IMessageRepository messageRepository,
|
||||
IFileStorageService fileStorage,
|
||||
IChatsUnitOfWork uow,
|
||||
IHubContext<ChatHub> hubContext)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_messageRepository = messageRepository;
|
||||
_fileStorage = fileStorage;
|
||||
_uow = uow;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
|
||||
@@ -36,19 +51,73 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
||||
return Result.Failure<SuccessResponse>(ChatErrors.Unauthorized);
|
||||
}
|
||||
|
||||
if (chat.Type == ChatType.Group)
|
||||
// If it's a private chat or the last member leaving a group, delete everything
|
||||
bool shouldDeleteEverything = chat.Type != ChatType.Group || chat.Members.Count <= 1;
|
||||
|
||||
if (chat.Type == ChatType.Group && !shouldDeleteEverything)
|
||||
{
|
||||
chat.RemoveMember(request.UserId);
|
||||
_chatRepository.Update(chat);
|
||||
}
|
||||
else
|
||||
{
|
||||
// DELETE ALL MESSAGES AND FILES FIRST
|
||||
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
|
||||
_chatRepository.Remove(chat);
|
||||
|
||||
// Notify all remaining members that the chat was deleted
|
||||
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
await _hubContext.Clients.User(member.UserId.ToString())
|
||||
.SendAsync("chat_deleted", chat.Id.ToString(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(new SuccessResponse(true));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
{
|
||||
// Get all messages directly from Mongo (not paged)
|
||||
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
|
||||
foreach (var msg in messages)
|
||||
{
|
||||
if (msg is Knot.Contracts.Messaging.Domain.MediaMessage mediaMsg)
|
||||
{
|
||||
foreach (var media in mediaMsg.Media)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(media.Url))
|
||||
{
|
||||
var fileId = ExtractFileId(media.Url);
|
||||
if (!string.IsNullOrEmpty(fileId))
|
||||
{
|
||||
await _fileStorage.DeleteFileAsync(fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await _messageRepository.DeleteChatMessagesAsync(chatId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log if possible, but don't fail chat deletion
|
||||
Console.WriteLine($"[Cleanup] Error deleting chat media: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string? ExtractFileId(string url)
|
||||
{
|
||||
var lastSlash = url.LastIndexOf('/');
|
||||
if (lastSlash == -1) return null;
|
||||
var id = url[(lastSlash + 1)..];
|
||||
if (id.Contains('?')) id = id[..id.IndexOf('?')];
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
|
||||
@@ -2,8 +2,8 @@ using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ public record ChatDto(
|
||||
DateTime CreatedAt,
|
||||
List<ChatMemberDto> Members,
|
||||
List<ChatMessageDto> Messages,
|
||||
int UnreadCount
|
||||
List<PinnedMessageDto> PinnedMessages,
|
||||
int UnreadCount,
|
||||
bool IsImporting = false,
|
||||
Guid? ImportJobId = null
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.DTOs;
|
||||
|
||||
@@ -21,6 +21,17 @@ public record ChatMessageDto(
|
||||
List<MediaDto> Media,
|
||||
MessageSenderDto Sender,
|
||||
List<ReactionDto> Reactions,
|
||||
List<ReadByDto> ReadBy
|
||||
List<ReadByDto> ReadBy,
|
||||
string? CallType = null,
|
||||
string? CallStatus = null,
|
||||
int? Duration = null,
|
||||
List<PollOptionDto>? PollOptions = null,
|
||||
bool? PollIsMultipleChoice = null,
|
||||
bool? PollIsAnonymous = null,
|
||||
bool? PollIsClosed = null,
|
||||
List<Guid>? UserVotedOptionIds = null
|
||||
);
|
||||
|
||||
public record PollOptionDto(Guid Id, string Text, int VoteCount, List<MessageSenderDto>? Voters = null, List<Guid>? VoterIds = null);
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.DTOs;
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace Knot.Modules.Conversations.Application.DTOs;
|
||||
public record MediaDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Url,
|
||||
string? Url,
|
||||
string? Filename,
|
||||
long? Size
|
||||
long? Size,
|
||||
string? Duration = null
|
||||
);
|
||||
|
||||
|
||||
@@ -24,7 +24,16 @@ public record MessageDetailDto(
|
||||
List<MediaDto> Media,
|
||||
MessageSenderDto? Sender,
|
||||
List<ReadByDto> ReadBy,
|
||||
List<MessageReactionDto> Reactions
|
||||
List<MessageReactionDto> Reactions,
|
||||
string? CallType = null,
|
||||
string? CallStatus = null,
|
||||
int? Duration = null,
|
||||
List<PollOptionDto>? PollOptions = null,
|
||||
bool? PollIsMultipleChoice = null,
|
||||
bool? PollIsAnonymous = null,
|
||||
bool? PollIsClosed = null,
|
||||
List<Guid>? UserVotedOptionIds = null,
|
||||
bool IsDeletedForUser = false
|
||||
);
|
||||
|
||||
public record ReplyToMessageDto(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.DTOs;
|
||||
|
||||
public static class MessageMapper
|
||||
{
|
||||
public static ChatMessageDto MapToDto(
|
||||
Message message,
|
||||
IReadOnlyDictionary<Guid, UserInfo> usersInfo,
|
||||
IEnumerable<MessageReaction> reactions,
|
||||
IEnumerable<Guid> readByUsers,
|
||||
Guid? currentUserId = null)
|
||||
{
|
||||
usersInfo.TryGetValue(message.SenderId, out var senderObj);
|
||||
|
||||
var reactionsWithUser = new List<ReactionDto>();
|
||||
foreach (var reaction in reactions)
|
||||
{
|
||||
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 textMessage = message as TextMessage;
|
||||
var mediaMessage = message as MediaMessage;
|
||||
var storyMessage = message as StoryMessage;
|
||||
var callMessage = message as CallMessage;
|
||||
|
||||
return new ChatMessageDto(
|
||||
message.Id,
|
||||
message.ChatId,
|
||||
message.SenderId,
|
||||
message.Content,
|
||||
message.Type,
|
||||
message.ReplyToId,
|
||||
textMessage?.Quote,
|
||||
storyMessage?.StoryId,
|
||||
storyMessage?.StoryMediaUrl,
|
||||
storyMessage?.StoryMediaType,
|
||||
message.IsEdited,
|
||||
message.IsDeleted,
|
||||
message.CreatedAt,
|
||||
message.SequenceId,
|
||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size, media.Duration)).ToList() ?? new List<MediaDto>(),
|
||||
senderObj != null ? new MessageSenderDto(
|
||||
senderObj.Id,
|
||||
senderObj.Username,
|
||||
senderObj.DisplayName,
|
||||
senderObj.Avatar
|
||||
) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
readByUsers.Select(id => new ReadByDto(id)).ToList(),
|
||||
callMessage?.CallType,
|
||||
callMessage?.CallStatus,
|
||||
callMessage?.Duration,
|
||||
message is PollMessage pm ? pm.Options.Select(o => {
|
||||
var voters = pm.IsAnonymous == false
|
||||
? pm.Votes
|
||||
.Where(v => v.OptionId == o.Id)
|
||||
.Select(v => {
|
||||
usersInfo.TryGetValue(v.UserId, out var vu);
|
||||
return vu != null
|
||||
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
||||
: new MessageSenderDto(v.UserId, "unknown", "Unknown", null);
|
||||
})
|
||||
.ToList()
|
||||
: null;
|
||||
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
|
||||
}).ToList() : null,
|
||||
(message as PollMessage)?.IsMultipleChoice,
|
||||
(message as PollMessage)?.IsAnonymous,
|
||||
(message as PollMessage)?.IsClosed,
|
||||
(message is PollMessage poll && currentUserId.HasValue) ? poll.Votes.Where(v => v.UserId == currentUserId.Value).Select(v => v.OptionId).ToList() : null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Knot.Modules.Conversations.Application.DTOs;
|
||||
|
||||
public record PinnedMessageDto(
|
||||
Guid Id,
|
||||
ChatMessageDto Message
|
||||
);
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Settings.Application.Abstractions;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
|
||||
+14
-19
@@ -1,5 +1,5 @@
|
||||
using global::Knot.Modules.Conversations.Application.Abstractions;
|
||||
using global::Knot.Modules.Conversations.Domain;
|
||||
using global::Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using global::Knot.Contracts.Conversations.Domain;
|
||||
using global::Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using global::Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
@@ -42,10 +42,16 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
||||
|
||||
if (request.DeleteForAll)
|
||||
{
|
||||
// Only message sender can delete for everyone
|
||||
if (message.SenderId == request.UserId)
|
||||
{
|
||||
message.Delete();
|
||||
}
|
||||
else
|
||||
{
|
||||
// If not the sender, just delete for current user
|
||||
message.DeleteForUser(request.UserId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -55,24 +61,13 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
if (request.DeleteForAll)
|
||||
// Notify all clients in the chat about the deletion
|
||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
||||
{
|
||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
messageIds = request.MessageIds,
|
||||
deleteForAll = true
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
messageIds = request.MessageIds,
|
||||
deleteForAll = false
|
||||
});
|
||||
}
|
||||
chatId = request.ChatId,
|
||||
messageIds = request.MessageIds,
|
||||
deleteForAll = request.DeleteForAll
|
||||
});
|
||||
|
||||
return global::Knot.Shared.Kernel.Result.Success();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Edit;
|
||||
|
||||
public sealed record EditMessageCommand(
|
||||
Guid MessageId,
|
||||
Guid ChatId,
|
||||
Guid UserId,
|
||||
string Content) : ICommand;
|
||||
|
||||
public sealed class EditMessageCommandHandler : ICommandHandler<EditMessageCommand>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
|
||||
public EditMessageCommandHandler(
|
||||
IMessageRepository messageRepository,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
IHubContext<ChatHub> hubContext)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(EditMessageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
|
||||
|
||||
if (message is null)
|
||||
{
|
||||
return Result.Failure(new Error("Message.NotFound", "Message not found."));
|
||||
}
|
||||
|
||||
if (message.SenderId != request.UserId)
|
||||
{
|
||||
return Result.Failure(new Error("Message.Forbidden", "You can only edit your own messages."));
|
||||
}
|
||||
|
||||
if (message.ChatId != request.ChatId)
|
||||
{
|
||||
return Result.Failure(new Error("Message.InvalidChat", "Message does not belong to this chat."));
|
||||
}
|
||||
|
||||
message.Edit(request.Content);
|
||||
|
||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Notify clients
|
||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("message_edited", new
|
||||
{
|
||||
messageId = message.Id,
|
||||
chatId = message.ChatId,
|
||||
content = message.Content,
|
||||
isEdited = true
|
||||
});
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+97
-49
@@ -3,17 +3,17 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
|
||||
|
||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor) : IQuery<List<MessageDetailDto>>;
|
||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, long? AfterSequenceId = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
||||
|
||||
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
||||
{
|
||||
@@ -38,15 +38,39 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
return Result.Failure<List<MessageDetailDto>>(ChatErrors.ChatsForbidden);
|
||||
}
|
||||
|
||||
DateTime? cursorDate = null;
|
||||
if (!string.IsNullOrEmpty(request.Cursor) && DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
|
||||
List<Message> messages;
|
||||
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
|
||||
|
||||
if (request.AfterSequenceId.HasValue)
|
||||
{
|
||||
cursorDate = parsed.ToUniversalTime();
|
||||
// Получаем только сообщения ПОСЛЕ указанного sequenceId (для синхронизации)
|
||||
messages = await _messageRepository.GetChatMessagesAfterAsync(request.ChatId, request.AfterSequenceId.Value, queryLimit, cancellationToken);
|
||||
}
|
||||
else if (request.Pivot.HasValue)
|
||||
{
|
||||
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
DateTime? cursorDate = null;
|
||||
long? cursorSequenceId = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Cursor))
|
||||
{
|
||||
if (long.TryParse(request.Cursor, out var seqId))
|
||||
{
|
||||
cursorSequenceId = seqId;
|
||||
}
|
||||
else if (DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
|
||||
{
|
||||
cursorDate = parsed.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
|
||||
messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, cursorSequenceId, queryLimit, cancellationToken);
|
||||
}
|
||||
|
||||
var messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, ChatConstants.DefaultMessageQueryLimit, cancellationToken);
|
||||
var result = new List<MessageDetailDto>();
|
||||
|
||||
var userIdsToFetch = new HashSet<Guid>();
|
||||
var replyMessages = new Dictionary<Guid, Message>();
|
||||
|
||||
@@ -57,6 +81,14 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
{
|
||||
userIdsToFetch.Add(m.SenderId);
|
||||
|
||||
if (m is PollMessage poll && !poll.IsAnonymous)
|
||||
{
|
||||
foreach (var vote in poll.Votes)
|
||||
{
|
||||
userIdsToFetch.Add(vote.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!m.ReplyToId.HasValue)
|
||||
{
|
||||
continue;
|
||||
@@ -85,36 +117,20 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
continue;
|
||||
}
|
||||
|
||||
ReplyToMessageDto? replyToObj = null;
|
||||
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)
|
||||
: null;
|
||||
senders.TryGetValue(message.SenderId, out var sender);
|
||||
reactionsByMessage.TryGetValue(message.Id, out var reactions);
|
||||
|
||||
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(),
|
||||
senderObj
|
||||
);
|
||||
|
||||
Message? replyMsg = null;
|
||||
if (message.ReplyToId.HasValue)
|
||||
{
|
||||
replyMessages.TryGetValue(message.ReplyToId.Value, out replyMsg);
|
||||
}
|
||||
|
||||
var reactionsWithUser = new List<MessageReactionDto>();
|
||||
var messageReactions = reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr : new List<MessageReaction>();
|
||||
foreach (var reaction in messageReactions)
|
||||
UserInfo? replySender = null;
|
||||
if (replyMsg != 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(
|
||||
reaction.Id,
|
||||
reaction.Emoji,
|
||||
reaction.UserId,
|
||||
userObj
|
||||
));
|
||||
senders.TryGetValue(replyMsg.SenderId, out replySender);
|
||||
}
|
||||
|
||||
result.Add(new MessageDetailDto(
|
||||
@@ -122,29 +138,61 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
message.ChatId,
|
||||
message.SenderId,
|
||||
message.Content,
|
||||
message.Type,
|
||||
message.Type.ToLower(),
|
||||
message.ReplyToId,
|
||||
replyToObj,
|
||||
message.Quote,
|
||||
replyMsg != null ? new ReplyToMessageDto(
|
||||
replyMsg.Id,
|
||||
replyMsg.Content,
|
||||
replyMsg.IsDeleted,
|
||||
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
|
||||
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
|
||||
) : null,
|
||||
(message as TextMessage)?.Quote,
|
||||
message.IsEdited,
|
||||
message.IsDeleted,
|
||||
message.CreatedAt,
|
||||
message.SequenceId,
|
||||
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,
|
||||
chat.Members.Where(m => m.LastReadSequenceId >= message.SequenceId && m.UserId != message.SenderId).Select(m => new ReadByDto(m.UserId)).ToList(),
|
||||
reactionsWithUser
|
||||
));
|
||||
null, // ForwardedFrom details not implemented here yet
|
||||
(message as StoryMessage)?.StoryId,
|
||||
(message as StoryMessage)?.StoryMediaUrl,
|
||||
(message as StoryMessage)?.StoryMediaType,
|
||||
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
||||
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(),
|
||||
reactions?.Select(r =>
|
||||
{
|
||||
senders.TryGetValue(r.UserId, out var ru);
|
||||
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
|
||||
}).ToList() ?? new List<MessageReactionDto>(),
|
||||
(message as CallMessage)?.CallType,
|
||||
(message as CallMessage)?.CallStatus,
|
||||
(message as CallMessage)?.Duration,
|
||||
(message as PollMessage)?.Options.Select(o =>
|
||||
{
|
||||
var pm = (PollMessage)message;
|
||||
var voters = pm.IsAnonymous == false
|
||||
? pm.Votes
|
||||
.Where(v => v.OptionId == o.Id)
|
||||
.Select(v =>
|
||||
{
|
||||
senders.TryGetValue(v.UserId, out var vu);
|
||||
return vu != null
|
||||
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
||||
: new MessageSenderDto(v.UserId, "unknown", "Unknown", null);
|
||||
})
|
||||
.ToList()
|
||||
: null;
|
||||
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
|
||||
}).ToList(),
|
||||
(message as PollMessage)?.IsMultipleChoice,
|
||||
(message as PollMessage)?.IsAnonymous,
|
||||
(message as PollMessage)?.IsClosed,
|
||||
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
|
||||
message.IsDeletedForUser(request.UserId)
|
||||
));
|
||||
}
|
||||
|
||||
return Result.Success(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+28
-26
@@ -6,9 +6,9 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
@@ -48,13 +48,20 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
var mediaMessage = message as MediaMessage;
|
||||
var textMessage = message as TextMessage;
|
||||
var storyMessage = message as StoryMessage;
|
||||
|
||||
if (filterType == "links")
|
||||
{
|
||||
var messageContent = message.Content;
|
||||
var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase);
|
||||
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 mediaLinks = (mediaMessage?.Media ?? Enumerable.Empty<Media>())
|
||||
.Where(media => media.Type?.ToString().ToLower() == "link" && !string.IsNullOrEmpty(media.Url))
|
||||
.Select(media => media.Url!)
|
||||
.ToList();
|
||||
var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList();
|
||||
|
||||
if (allLinks.Any())
|
||||
@@ -72,7 +79,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
||||
continue;
|
||||
}
|
||||
|
||||
var messageMedia = message.Media;
|
||||
var messageMedia = mediaMessage?.Media;
|
||||
if (messageMedia == null || !messageMedia.Any())
|
||||
{
|
||||
continue;
|
||||
@@ -80,23 +87,18 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
||||
|
||||
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));
|
||||
var mType = media.Type?.ToLower() ?? "file";
|
||||
var filename = media.Filename?.ToLower() ?? "";
|
||||
var url = media.Url?.ToLower() ?? "";
|
||||
|
||||
var isGif = mType == "gif" ||
|
||||
(mType == "image" && (filename.EndsWith(".mp4") || filename.EndsWith(".gif") || url.EndsWith(".gif") || filename.Contains("gif"))) ||
|
||||
(mType == "video" && (filename.Contains("animation") || filename.Contains("gif")));
|
||||
|
||||
if (filterType == "gifs")
|
||||
{
|
||||
return isGif;
|
||||
}
|
||||
|
||||
if (filterType == "files")
|
||||
{
|
||||
return mediaType != "image" && mediaType != "video" && mediaType != "link";
|
||||
}
|
||||
|
||||
if (filterType == "media")
|
||||
{
|
||||
return mediaType == "image" || mediaType == "video";
|
||||
}
|
||||
if (filterType == "gifs") return isGif;
|
||||
if (filterType == "media") return (mType == "image" || mType == "video") && !isGif;
|
||||
if (filterType == "files") return (mType == "file" || mType == "audio") && !isGif && mType != "image" && mType != "video";
|
||||
if (filterType == "links") return mType == "link";
|
||||
|
||||
return true;
|
||||
}).ToList();
|
||||
@@ -111,13 +113,13 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
||||
null,
|
||||
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null,
|
||||
message.ReplyToId,
|
||||
message.Quote,
|
||||
message.StoryId,
|
||||
message.StoryMediaUrl,
|
||||
message.StoryMediaType,
|
||||
textMessage?.Quote,
|
||||
storyMessage?.StoryId,
|
||||
storyMessage?.StoryMediaUrl,
|
||||
storyMessage?.StoryMediaType,
|
||||
message.IsEdited,
|
||||
message.Type,
|
||||
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList()
|
||||
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size, media.Duration)).ToList()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Pin;
|
||||
|
||||
public sealed record PinMessageCommand(Guid MessageId, Guid ChatId, Guid UserId) : ICommand<Guid>;
|
||||
|
||||
public sealed class PinMessageCommandHandler : ICommandHandler<PinMessageCommand, Guid>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public PinMessageCommandHandler(
|
||||
IMessageRepository messageRepository,
|
||||
IChatRepository chatRepository,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
IMediator mediator)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(PinMessageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat is null) return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
||||
|
||||
// Security check
|
||||
if (!chat.Members.Any(m => m.UserId == request.UserId))
|
||||
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
||||
|
||||
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
|
||||
if (message is null) return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||
|
||||
if (message.ChatId != request.ChatId)
|
||||
return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||
|
||||
message.AddState(MessageState.IsPinned);
|
||||
|
||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Notify chat about pinned message change
|
||||
await _mediator.Publish(new MessagePinnedDomainEvent(message.Id, message.ChatId, message.SenderId, message.Content), cancellationToken);
|
||||
|
||||
return Result.Success(message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public record MessagePinnedDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : INotification;
|
||||
@@ -1,7 +1,7 @@
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
+24
-4
@@ -1,7 +1,8 @@
|
||||
using MediatR;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
||||
|
||||
@@ -11,11 +12,13 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
|
||||
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
|
||||
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
|
||||
@@ -28,6 +31,23 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
|
||||
|
||||
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
|
||||
|
||||
// Обновляем ReadByUsers для всех сообщений до LastReadSequenceId
|
||||
var messages = await _messageRepository.GetChatMessagesAfterAsync(
|
||||
request.ChatId,
|
||||
0,
|
||||
1000,
|
||||
cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (message.SequenceId <= request.LastReadSequenceId &&
|
||||
message.SenderId != request.UserId &&
|
||||
!message.IsReadBy(request.UserId))
|
||||
{
|
||||
message.MarkAsRead(request.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
|
||||
+21
-14
@@ -3,10 +3,10 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
@@ -41,28 +41,35 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
||||
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
|
||||
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var result = messages.Select(message => new SearchMessageDto(
|
||||
message.Id,
|
||||
message.ChatId,
|
||||
message.SenderId,
|
||||
var result = messages.Select(message =>
|
||||
{
|
||||
var textMessage = message as TextMessage;
|
||||
var mediaMessage = message as MediaMessage;
|
||||
var storyMessage = message as StoryMessage;
|
||||
|
||||
return new SearchMessageDto(
|
||||
message.Id,
|
||||
message.ChatId,
|
||||
message.SenderId,
|
||||
message.Content,
|
||||
message.Type,
|
||||
message.ReplyToId,
|
||||
message.Quote,
|
||||
textMessage?.Quote,
|
||||
message.IsEdited,
|
||||
message.IsDeleted,
|
||||
message.CreatedAt,
|
||||
message.SequenceId,
|
||||
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),
|
||||
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
|
||||
new List<ReadByDto>()
|
||||
)).ToList();
|
||||
storyMessage?.StoryId,
|
||||
storyMessage?.StoryMediaUrl,
|
||||
storyMessage?.StoryMediaType,
|
||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
||||
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
|
||||
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList()
|
||||
);
|
||||
}).ToList();
|
||||
|
||||
return Result.Success(result);
|
||||
}
|
||||
|
||||
+21
-4
@@ -1,8 +1,8 @@
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Settings.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
||||
@@ -27,7 +27,10 @@ public sealed record SendMessageCommand(
|
||||
List<string>? PollOptions = null,
|
||||
bool? PollIsAnonymous = null,
|
||||
bool? PollAllowMultipleAnswers = null,
|
||||
DateTime? PollExpiresAt = null) : ICommand<Guid>;
|
||||
DateTime? PollExpiresAt = null,
|
||||
string? CallType = null,
|
||||
string? CallStatus = null,
|
||||
int? Duration = null) : ICommand<Guid>;
|
||||
|
||||
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
||||
{
|
||||
@@ -132,8 +135,9 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
else if (request.Type == "poll")
|
||||
{
|
||||
if (!_messagesSettings.Current.AllowPolls) return Result.Failure<Guid>(ChatErrors.PollsDisabled);
|
||||
if (chat.Type != ChatType.Group) return Result.Failure<Guid>(new Error("Poll.InvalidChat", "Polls are only allowed in groups."));
|
||||
|
||||
message = new PollMessage(
|
||||
message = PollMessage.Create(
|
||||
Guid.NewGuid(),
|
||||
request.ChatId,
|
||||
request.SenderId,
|
||||
@@ -143,6 +147,18 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
request.PollAllowMultipleAnswers ?? false,
|
||||
request.PollExpiresAt,
|
||||
request.ReplyToId,
|
||||
request.ForwardedFromId);
|
||||
}
|
||||
else if (request.Type == "call")
|
||||
{
|
||||
message = new CallMessage(
|
||||
Guid.NewGuid(),
|
||||
request.ChatId,
|
||||
request.SenderId,
|
||||
request.CallType ?? "voice",
|
||||
request.CallStatus ?? "completed",
|
||||
request.Duration,
|
||||
request.ReplyToId,
|
||||
request.ForwardedFromId,
|
||||
DateTime.UtcNow,
|
||||
false);
|
||||
@@ -176,6 +192,7 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
|
||||
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
||||
senderMember.UpdateDeliveredCursor(message.Id);
|
||||
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
|
||||
|
||||
// 5. ���������
|
||||
_messageRepository.Add(message);
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Unpin;
|
||||
|
||||
public sealed record UnpinMessageCommand(Guid MessageId, Guid ChatId, Guid UserId) : ICommand<Guid>;
|
||||
|
||||
public sealed class UnpinMessageCommandHandler : ICommandHandler<UnpinMessageCommand, Guid>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public UnpinMessageCommandHandler(
|
||||
IMessageRepository messageRepository,
|
||||
IChatRepository chatRepository,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
IMediator mediator)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(UnpinMessageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat is null) return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
||||
|
||||
// Security check
|
||||
if (!chat.Members.Any(m => m.UserId == request.UserId))
|
||||
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
||||
|
||||
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
|
||||
if (message is null) return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||
|
||||
if (message.ChatId != request.ChatId)
|
||||
return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||
|
||||
message.RemoveState(MessageState.IsPinned);
|
||||
|
||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Notify chat about unpinned message change
|
||||
await _mediator.Publish(new MessageUnpinnedDomainEvent(message.Id, message.ChatId), cancellationToken);
|
||||
|
||||
return Result.Success(message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public record MessageUnpinnedDomainEvent(Guid MessageId, Guid ChatId) : INotification;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Vote;
|
||||
|
||||
public sealed record VotePollCommand(
|
||||
Guid MessageId,
|
||||
Guid ChatId,
|
||||
Guid UserId,
|
||||
Guid OptionId) : ICommand;
|
||||
|
||||
public sealed class VotePollCommandHandler : ICommandHandler<VotePollCommand>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IMessageNotifier _notifier;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
|
||||
public VotePollCommandHandler(
|
||||
IMessageRepository messageRepository,
|
||||
IChatRepository chatRepository,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
IMessageNotifier notifier,
|
||||
IUserDisplayNameProvider userProvider)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_notifier = notifier;
|
||||
_userProvider = userProvider;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(VotePollCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
|
||||
if (message is not PollMessage poll) return Result.Failure(new Error("Poll.NotFound", "Poll not found"));
|
||||
|
||||
if (poll.IsClosed) return Result.Failure(new Error("Poll.Closed", "This poll is closed."));
|
||||
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId)) return Result.Failure(ChatErrors.ChatsForbidden);
|
||||
|
||||
var targetOption = poll.Options.FirstOrDefault(o => o.Id == request.OptionId);
|
||||
if (targetOption == null) return Result.Failure(new Error("Poll.InvalidOption", "Invalid option ID."));
|
||||
|
||||
// Prevent duplicate or changed votes
|
||||
var existingVote = poll.Votes.FirstOrDefault(v => v.UserId == request.UserId && v.OptionId == request.OptionId);
|
||||
if (existingVote != null) return Result.Failure(new Error("Poll.AlreadyVoted", "You have already voted for this option."));
|
||||
|
||||
if (!poll.IsMultipleChoice)
|
||||
{
|
||||
var hasVotedInThisPoll = poll.Votes.Any(v => v.UserId == request.UserId);
|
||||
if (hasVotedInThisPoll) return Result.Failure(new Error("Poll.AlreadyVoted", "You have already voted in this poll."));
|
||||
}
|
||||
|
||||
poll.Votes.Add(new PollVote { UserId = request.UserId, OptionId = request.OptionId, VotedAt = DateTime.UtcNow });
|
||||
targetOption.VoteCount++;
|
||||
|
||||
await _messageRepository.UpdateAsync(poll, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Notify updated poll
|
||||
var voterIds = poll.Votes.Select(v => v.UserId).Distinct().ToList();
|
||||
var votersInfo = poll.IsAnonymous == false
|
||||
? await _userProvider.GetUsersInfoAsync(voterIds, cancellationToken)
|
||||
: new Dictionary<Guid, UserInfo>();
|
||||
|
||||
await _notifier.NotifyMessageUpdateAsync(poll.ChatId, "poll_updated", new
|
||||
{
|
||||
id = poll.Id,
|
||||
chatId = poll.ChatId,
|
||||
senderId = poll.SenderId,
|
||||
createdAt = poll.CreatedAt,
|
||||
type = "poll",
|
||||
content = poll.Content,
|
||||
pollOptions = poll.Options.Select(o => new {
|
||||
id = o.Id,
|
||||
text = o.Text,
|
||||
voteCount = o.VoteCount,
|
||||
voters = poll.IsAnonymous == false
|
||||
? poll.Votes.Where(v => v.OptionId == o.Id)
|
||||
.Select(v => {
|
||||
votersInfo.TryGetValue(v.UserId, out var vu);
|
||||
return vu != null
|
||||
? new { id = vu.Id, username = vu.Username, displayName = vu.DisplayName, avatar = vu.Avatar }
|
||||
: new { id = v.UserId, username = "unknown", displayName = "Unknown", avatar = (string?)null };
|
||||
}).ToList()
|
||||
: null,
|
||||
voterIds = poll.IsAnonymous == false
|
||||
? poll.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList()
|
||||
: null
|
||||
}).ToList(),
|
||||
pollIsMultipleChoice = poll.IsMultipleChoice,
|
||||
pollIsClosed = poll.IsClosed,
|
||||
pollIsAnonymous = poll.IsAnonymous
|
||||
}, cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+12
-9
@@ -2,8 +2,8 @@ using System.Text.RegularExpressions;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using MediatR;
|
||||
@@ -55,15 +55,18 @@ internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserComma
|
||||
{
|
||||
foreach (var media in mediaMsg.Media)
|
||||
{
|
||||
bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id &&
|
||||
m is MediaMessage mm && mm.Media.Any(ame => ame.Url == media.Url));
|
||||
|
||||
if (!isUsedElsewhere)
|
||||
if (!string.IsNullOrEmpty(media.Url))
|
||||
{
|
||||
var fileId = ExtractFileId(media.Url);
|
||||
if (!string.IsNullOrEmpty(fileId))
|
||||
bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id &&
|
||||
m is MediaMessage mm && mm.Media.Any(ame => !string.IsNullOrEmpty(ame.Url) && ame.Url == media.Url));
|
||||
|
||||
if (!isUsedElsewhere)
|
||||
{
|
||||
await _fileStorage.DeleteFileAsync(fileId);
|
||||
var fileId = ExtractFileId(media.Url);
|
||||
if (!string.IsNullOrEmpty(fileId))
|
||||
{
|
||||
await _fileStorage.DeleteFileAsync(fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
||||
using Knot.Modules.Conversations.Infrastructure.Services;
|
||||
using Knot.Shared.Kernel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ConversationsAbstractions = Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Conversations;
|
||||
|
||||
@@ -29,19 +27,22 @@ public static class DependencyInjection
|
||||
|
||||
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
|
||||
|
||||
services.AddScoped<ConversationsAbstractions.IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
|
||||
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
|
||||
services.AddScoped<Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext>(sp => sp.GetRequiredService<ChatsDbContext>());
|
||||
services.AddScoped<IChatRepository, ChatRepository>();
|
||||
services.AddScoped<IFolderRepository, FolderRepository>();
|
||||
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
|
||||
services.AddScoped<IUserFolderSettingsRepository, UserFolderSettingsRepository>();
|
||||
services.AddScoped<Knot.Contracts.Conversations.Domain.IUserFolderSettingsRepository, UserFolderSettingsRepository>();
|
||||
|
||||
services.AddMediatR(config =>
|
||||
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
|
||||
|
||||
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>();
|
||||
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IChatAccessProvider, ChatAccessProvider>();
|
||||
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
|
||||
services.AddScoped<ConversationsAbstractions.IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
|
||||
services.AddScoped<Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService, UserStatusService>();
|
||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
|
||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Conversations.Domain;
|
||||
|
||||
public sealed record ChatCreatedDomainEvent(Chat Chat) : IDomainEvent;
|
||||
public sealed record ChatMemberAddedDomainEvent(Guid ChatId, Guid UserId) : IDomainEvent;
|
||||
|
||||
/// <summary>
|
||||
/// Тип чата: личный или групповой.
|
||||
/// </summary>
|
||||
public enum ChatType
|
||||
{
|
||||
Personal,
|
||||
Group,
|
||||
Favorites
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Роль участника в чате.
|
||||
/// </summary>
|
||||
public static class ChatRole
|
||||
{
|
||||
public const string Owner = "owner";
|
||||
public const string Admin = "admin";
|
||||
public const string Member = "member";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сущность чата (Агрегат).
|
||||
/// </summary>
|
||||
public sealed class Chat : AggregateRoot<Guid>
|
||||
{
|
||||
public ChatType Type { get; private set; }
|
||||
public string? Name { get; private set; }
|
||||
public string? Description { get; private set; }
|
||||
public string? Avatar { get; private set; }
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
public long LastMessageSequenceId { get; private set; }
|
||||
|
||||
private readonly List<ChatMember> _members = new();
|
||||
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
|
||||
|
||||
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null) : base(id)
|
||||
{
|
||||
Type = type;
|
||||
Name = name;
|
||||
Avatar = avatar;
|
||||
Description = description;
|
||||
CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создает личный чат между двумя пользователями.
|
||||
/// </summary>
|
||||
public static Chat CreatePersonal()
|
||||
{
|
||||
var chat = new Chat(Guid.NewGuid(), ChatType.Personal, null, null);
|
||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
||||
return chat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создает групповой чат.
|
||||
/// </summary>
|
||||
public static Chat CreateGroup(string name, string? avatar = null)
|
||||
{
|
||||
var chat = new Chat(Guid.NewGuid(), ChatType.Group, name, avatar);
|
||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
||||
return chat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Фабричный метод для создания чата.
|
||||
/// </summary>
|
||||
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null)
|
||||
{
|
||||
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description);
|
||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
||||
return chat;
|
||||
}
|
||||
|
||||
public void AddMember(Guid userId, string role = "member")
|
||||
{
|
||||
if (_members.Any(m => m.UserId == userId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_members.Add(new ChatMember(Id, userId, role));
|
||||
RaiseDomainEvent(new ChatMemberAddedDomainEvent(Id, userId));
|
||||
}
|
||||
|
||||
public void RemoveMember(Guid userId)
|
||||
{
|
||||
var member = _members.FirstOrDefault(m => m.UserId == userId);
|
||||
if (member != null)
|
||||
{
|
||||
_members.Remove(member);
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateName(string name) => Name = name;
|
||||
|
||||
public void UpdateDescription(string? description) => Description = description;
|
||||
|
||||
public void UpdateAvatar(string? avatarUrl) => Avatar = avatarUrl;
|
||||
|
||||
public long IncrementSequenceId()
|
||||
{
|
||||
return ++LastMessageSequenceId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Участник чата.
|
||||
/// </summary>
|
||||
public sealed class ChatMember : Entity<Guid>
|
||||
{
|
||||
public Guid ChatId { get; private set; }
|
||||
public Guid UserId { get; private set; }
|
||||
public string Role { get; private set; }
|
||||
public DateTime JoinedAt { get; private set; }
|
||||
public bool IsPinned { get; private set; }
|
||||
public bool IsMuted { get; private set; }
|
||||
|
||||
public Guid? LastReadMessageId { get; private set; }
|
||||
public long LastReadSequenceId { get; private set; }
|
||||
public Guid? LastDeliveredMessageId { get; private set; }
|
||||
|
||||
// For EF Core
|
||||
private ChatMember() : base(Guid.Empty) { Role = "member"; }
|
||||
|
||||
internal ChatMember(Guid chatId, Guid userId, string role) : base(Guid.NewGuid())
|
||||
{
|
||||
ChatId = chatId;
|
||||
UserId = userId;
|
||||
Role = role;
|
||||
JoinedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void TogglePin() => IsPinned = !IsPinned;
|
||||
|
||||
public void UpdateReadCursor(Guid messageId, long sequenceId)
|
||||
{
|
||||
if (sequenceId > LastReadSequenceId)
|
||||
{
|
||||
LastReadMessageId = messageId;
|
||||
LastReadSequenceId = sequenceId;
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateDeliveredCursor(Guid messageId)
|
||||
{
|
||||
LastDeliveredMessageId = messageId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Knot.Modules.Conversations.Domain;
|
||||
|
||||
public static class ChatConstants
|
||||
{
|
||||
public const int DefaultMessageQueryLimit = 100;
|
||||
public const int MaxSharedMediaQueryLimit = 300;
|
||||
public const int SearchMessagesLimit = 50;
|
||||
public const int MaxFileUploadSizeMb = 50;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user