diff --git a/.dockerignore b/.dockerignore
index cbae169..d9eb67c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -7,3 +7,7 @@ apps/server/dist
apps/server/uploads
.vscode
.env
+bin/
+obj/
+**/bin/
+**/obj/
diff --git a/Dockerfile.server-net b/Dockerfile.server-net
new file mode 100644
index 0000000..ffa41eb
--- /dev/null
+++ b/Dockerfile.server-net
@@ -0,0 +1,23 @@
+FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
+WORKDIR /app
+
+# Copy everything and restore as distinct layers
+COPY . .
+RUN dotnet restore apps/server-net/Vortex.sln
+
+# Build and publish a release
+RUN dotnet publish apps/server-net/src/Vortex.Host/Vortex.Host.csproj -c Release -o out
+
+# Build runtime image
+FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview
+WORKDIR /app
+
+# Install native dependencies for Npgsql/GSSAPI
+RUN apt-get update && apt-get install -y libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/*
+
+COPY --from=build /app/out .
+
+EXPOSE 8080
+ENV ASPNETCORE_URLS=http://+:8080
+
+ENTRYPOINT ["dotnet", "Vortex.Host.dll"]
diff --git a/Dockerfile.web b/Dockerfile.web
index 3f1402b..1d7a5ec 100644
--- a/Dockerfile.web
+++ b/Dockerfile.web
@@ -5,7 +5,6 @@ WORKDIR /app
# ROOT package.json
COPY package*.json ./
-COPY apps/server/package*.json ./apps/server/
COPY apps/web/package*.json ./apps/web/
RUN npm install
diff --git a/apps/server-net/Vortex.sln b/apps/server-net/Vortex.sln
new file mode 100644
index 0000000..283306d
--- /dev/null
+++ b/apps/server-net/Vortex.sln
@@ -0,0 +1,128 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{C8E42992-5E42-0C2B-DBFE-AA848D06431C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vortex.Shared.Kernel", "src\Shared\Vortex.Shared.Kernel\Vortex.Shared.Kernel.csproj", "{C829F859-FF48-4025-BF22-8E63672D113C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vortex.Shared.Infrastructure", "src\Shared\Vortex.Shared.Infrastructure\Vortex.Shared.Infrastructure.csproj", "{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{0EC62A1A-9858-3A60-8A0C-FC9AACFA2EC7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vortex.Modules.Identity", "src\Modules\Identity\Vortex.Modules.Identity.csproj", "{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Chats", "Chats", "{B4154CE3-9153-E2B0-0515-138FFCE32938}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vortex.Modules.Chats", "src\Modules\Chats\Vortex.Modules.Chats.csproj", "{695CC919-E321-4DC0-8D70-C631C51624E1}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vortex.Modules.Identity.UnitTests", "tests\Vortex.Modules.Identity.UnitTests\Vortex.Modules.Identity.UnitTests.csproj", "{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vortex.Modules.Chats.UnitTests", "tests\Vortex.Modules.Chats.UnitTests\Vortex.Modules.Chats.UnitTests.csproj", "{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Debug|x64.Build.0 = Debug|Any CPU
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Debug|x86.Build.0 = Debug|Any CPU
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Release|x64.ActiveCfg = Release|Any CPU
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Release|x64.Build.0 = Release|Any CPU
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Release|x86.ActiveCfg = Release|Any CPU
+ {C829F859-FF48-4025-BF22-8E63672D113C}.Release|x86.Build.0 = Release|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|x64.Build.0 = Debug|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|x86.Build.0 = Debug|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|x64.ActiveCfg = Release|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|x64.Build.0 = Release|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|x86.ActiveCfg = Release|Any CPU
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|x86.Build.0 = Release|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|x64.Build.0 = Debug|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|x86.Build.0 = Debug|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|x64.ActiveCfg = Release|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|x64.Build.0 = Release|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|x86.ActiveCfg = Release|Any CPU
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|x86.Build.0 = Release|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|x64.Build.0 = Debug|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|x86.Build.0 = Debug|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Release|x64.ActiveCfg = Release|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Release|x64.Build.0 = Release|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Release|x86.ActiveCfg = Release|Any CPU
+ {695CC919-E321-4DC0-8D70-C631C51624E1}.Release|x86.Build.0 = Release|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|x64.Build.0 = Debug|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|x86.Build.0 = Debug|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|x64.ActiveCfg = Release|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|x64.Build.0 = Release|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|x86.ActiveCfg = Release|Any CPU
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|x86.Build.0 = Release|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|x64.Build.0 = Debug|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|x86.Build.0 = Debug|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|x64.ActiveCfg = Release|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|x64.Build.0 = Release|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|x86.ActiveCfg = Release|Any CPU
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {C8E42992-5E42-0C2B-DBFE-AA848D06431C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {C829F859-FF48-4025-BF22-8E63672D113C} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
+ {6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
+ {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {0EC62A1A-9858-3A60-8A0C-FC9AACFA2EC7} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
+ {5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98} = {0EC62A1A-9858-3A60-8A0C-FC9AACFA2EC7}
+ {B4154CE3-9153-E2B0-0515-138FFCE32938} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
+ {695CC919-E321-4DC0-8D70-C631C51624E1} = {B4154CE3-9153-E2B0-0515-138FFCE32938}
+ {AB2F334E-DD7C-4314-93B9-941AFC6B2ACD} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ EndGlobalSection
+EndGlobal
diff --git a/apps/server-net/src/Modules/Chats/Application/Abstractions/IChatsUnitOfWork.cs b/apps/server-net/src/Modules/Chats/Application/Abstractions/IChatsUnitOfWork.cs
new file mode 100644
index 0000000..d24196e
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Application/Abstractions/IChatsUnitOfWork.cs
@@ -0,0 +1,10 @@
+using Vortex.Shared.Kernel;
+
+namespace Vortex.Modules.Chats.Application.Abstractions;
+
+///
+/// Unit of Work специфичный для модуля Chats.
+///
+public interface IChatsUnitOfWork : IUnitOfWork
+{
+}
diff --git a/apps/server-net/src/Modules/Chats/Application/Chats/Create/CreateChatCommandHandler.cs b/apps/server-net/src/Modules/Chats/Application/Chats/Create/CreateChatCommandHandler.cs
new file mode 100644
index 0000000..05c1325
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Application/Chats/Create/CreateChatCommandHandler.cs
@@ -0,0 +1,41 @@
+using Vortex.Modules.Chats.Domain;
+using Vortex.Shared.Kernel;
+
+using Vortex.Modules.Chats.Application.Abstractions;
+
+namespace Vortex.Modules.Chats.Application.Chats.Create;
+
+///
+/// Команда для создания чата.
+///
+public sealed record CreateChatCommand(
+ string Name,
+ ChatType Type,
+ List MemberIds) : ICommand;
+
+public sealed class CreateChatCommandHandler : ICommandHandler
+{
+ private readonly IChatRepository _chatRepository;
+ private readonly IChatsUnitOfWork _unitOfWork;
+
+ public CreateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
+ {
+ _chatRepository = chatRepository;
+ _unitOfWork = unitOfWork;
+ }
+
+ public async Task> Handle(CreateChatCommand request, CancellationToken cancellationToken)
+ {
+ var chat = Chat.Create(request.Name, request.Type);
+
+ foreach (var userId in request.MemberIds)
+ {
+ chat.AddMember(userId, ChatRole.Member);
+ }
+
+ _chatRepository.Add(chat);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(chat.Id);
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Application/Chats/GetOrCreateFavorites/GetOrCreateFavoritesCommandHandler.cs b/apps/server-net/src/Modules/Chats/Application/Chats/GetOrCreateFavorites/GetOrCreateFavoritesCommandHandler.cs
new file mode 100644
index 0000000..aee07bc
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Application/Chats/GetOrCreateFavorites/GetOrCreateFavoritesCommandHandler.cs
@@ -0,0 +1,38 @@
+using Vortex.Modules.Chats.Domain;
+using Vortex.Shared.Kernel;
+using Vortex.Modules.Chats.Application.Abstractions;
+
+namespace Vortex.Modules.Chats.Application.Chats.GetOrCreateFavorites;
+
+public sealed record GetOrCreateFavoritesCommand(Guid UserId) : ICommand;
+
+public sealed class GetOrCreateFavoritesCommandHandler : ICommandHandler
+{
+ private readonly IChatRepository _chatRepository;
+ private readonly IChatsUnitOfWork _unitOfWork;
+
+ public GetOrCreateFavoritesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
+ {
+ _chatRepository = chatRepository;
+ _unitOfWork = unitOfWork;
+ }
+
+ public async Task> Handle(GetOrCreateFavoritesCommand request, CancellationToken cancellationToken)
+ {
+ var favorites = await _chatRepository.GetFavoritesAsync(request.UserId, cancellationToken);
+
+ if (favorites != null)
+ {
+ return Result.Success(favorites.Id);
+ }
+
+ // Create new favorites chat
+ var chat = Chat.Create("Избранное", ChatType.Favorites);
+ chat.AddMember(request.UserId, ChatRole.Owner);
+
+ _chatRepository.Add(chat);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(chat.Id);
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Application/Messages/Read/ReadMessagesCommandHandler.cs b/apps/server-net/src/Modules/Chats/Application/Messages/Read/ReadMessagesCommandHandler.cs
new file mode 100644
index 0000000..aa59add
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Application/Messages/Read/ReadMessagesCommandHandler.cs
@@ -0,0 +1,34 @@
+using MediatR;
+using Vortex.Modules.Chats.Domain;
+using Vortex.Shared.Kernel;
+using Vortex.Modules.Chats.Application.Abstractions;
+
+namespace Vortex.Modules.Chats.Application.Messages.Read;
+
+public sealed record ReadMessagesCommand(Guid ChatId, Guid UserId, List MessageIds) : ICommand;
+
+public sealed class ReadMessagesCommandHandler : ICommandHandler
+{
+ private readonly IMessageRepository _messageRepository;
+ private readonly IChatsUnitOfWork _unitOfWork;
+
+ public ReadMessagesCommandHandler(IMessageRepository messageRepository, IChatsUnitOfWork unitOfWork)
+ {
+ _messageRepository = messageRepository;
+ _unitOfWork = unitOfWork;
+ }
+
+ public async Task Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
+ {
+ if (request.MessageIds == null || !request.MessageIds.Any())
+ {
+ return Result.Success();
+ }
+
+ await _messageRepository.AddReadReceiptsAsync(request.UserId, request.MessageIds, cancellationToken);
+
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success();
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Application/Messages/Send/SendMessageCommandHandler.cs b/apps/server-net/src/Modules/Chats/Application/Messages/Send/SendMessageCommandHandler.cs
new file mode 100644
index 0000000..c33ed7e
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Application/Messages/Send/SendMessageCommandHandler.cs
@@ -0,0 +1,66 @@
+using Vortex.Modules.Chats.Domain;
+using Vortex.Shared.Kernel;
+using Vortex.Modules.Chats.Application.Abstractions;
+
+namespace Vortex.Modules.Chats.Application.Messages.Send;
+
+///
+/// Команда для отправки сообщения в чат.
+///
+public sealed record SendMessageCommand(
+ Guid ChatId,
+ Guid SenderId,
+ string? Content,
+ string Type,
+ string? MediaUrl = null,
+ string? MediaType = null,
+ string? FileName = null,
+ long? FileSize = null) : ICommand;
+
+public sealed class SendMessageCommandHandler : ICommandHandler
+{
+ private readonly IChatRepository _chatRepository;
+ private readonly IMessageRepository _messageRepository;
+ private readonly IChatsUnitOfWork _unitOfWork;
+
+ public SendMessageCommandHandler(
+ IChatRepository chatRepository,
+ IMessageRepository messageRepository,
+ IChatsUnitOfWork unitOfWork)
+ {
+ _chatRepository = chatRepository;
+ _messageRepository = messageRepository;
+ _unitOfWork = unitOfWork;
+ }
+
+ public async Task> Handle(SendMessageCommand request, CancellationToken cancellationToken)
+ {
+ // 1. Проверяем существование чата
+ var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
+ if (chat is null)
+ {
+ return Result.Failure(new Error("Chats.NotFound", "Чат не найден."));
+ }
+
+ // 2. Проверяем, является ли отправитель участником (в будущем)
+ // if (!chat.Members.Any(m => m.UserId == request.SenderId)) ...
+
+ // 3. Создаем сообщение
+ var message = Message.Create(
+ request.ChatId,
+ request.SenderId,
+ request.Content,
+ request.Type);
+
+ if (!string.IsNullOrEmpty(request.MediaUrl) && !string.IsNullOrEmpty(request.MediaType))
+ {
+ message.AddMedia(request.MediaType, request.MediaUrl, request.FileName, request.FileSize);
+ }
+
+ // 4. Сохраняем
+ _messageRepository.Add(message);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(message.Id);
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Class1.cs b/apps/server-net/src/Modules/Chats/Class1.cs
new file mode 100644
index 0000000..38a984a
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Class1.cs
@@ -0,0 +1,6 @@
+namespace Vortex.Modules.Chats;
+
+public class Class1
+{
+
+}
diff --git a/apps/server-net/src/Modules/Chats/DependencyInjection.cs b/apps/server-net/src/Modules/Chats/DependencyInjection.cs
new file mode 100644
index 0000000..60c3d7a
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/DependencyInjection.cs
@@ -0,0 +1,38 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.EntityFrameworkCore;
+using Vortex.Modules.Chats.Infrastructure.Persistence;
+using Vortex.Modules.Chats.Domain;
+using Vortex.Shared.Kernel;
+
+using Vortex.Modules.Chats.Application.Abstractions;
+
+namespace Vortex.Modules.Chats;
+
+public static class DependencyInjection
+{
+ ///
+ /// Регистрация сервисов модуля Chats.
+ ///
+ public static IServiceCollection AddChatsModule(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ // Настройка базы данных
+ string connectionString = configuration.GetConnectionString("Database")!;
+
+ services.AddDbContext(options =>
+ options.UseNpgsql(connectionString));
+
+ // Регистрация Unit of Work и Репозиториев
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.AddScoped();
+ services.AddScoped();
+
+ // Регистрация MediatR для этого модуля
+ services.AddMediatR(config =>
+ config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
+
+ return services;
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Domain/Chat.cs b/apps/server-net/src/Modules/Chats/Domain/Chat.cs
new file mode 100644
index 0000000..8e17b2f
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Domain/Chat.cs
@@ -0,0 +1,108 @@
+using Vortex.Shared.Kernel;
+
+namespace Vortex.Modules.Chats.Domain;
+
+///
+/// Тип чата: личный или групповой.
+///
+public enum ChatType
+{
+ Personal,
+ Group,
+ Favorites
+}
+
+///
+/// Роль участника в чате.
+///
+public static class ChatRole
+{
+ public const string Owner = "owner";
+ public const string Admin = "admin";
+ public const string Member = "member";
+}
+
+///
+/// Сущность чата (Агрегат).
+///
+public sealed class Chat : AggregateRoot
+{
+ public ChatType Type { get; private set; }
+ public string? Name { get; private set; }
+ public string? Avatar { get; private set; }
+ public DateTime CreatedAt { get; private set; }
+
+ private readonly List _members = new();
+ public IReadOnlyCollection Members => _members.AsReadOnly();
+
+ private Chat(Guid id, ChatType type, string? name, string? avatar) : base(id)
+ {
+ Type = type;
+ Name = name;
+ Avatar = avatar;
+ CreatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Создает личный чат между двумя пользователями.
+ ///
+ public static Chat CreatePersonal()
+ {
+ return new Chat(Guid.NewGuid(), ChatType.Personal, null, null);
+ }
+
+ ///
+ /// Создает групповой чат.
+ ///
+ public static Chat CreateGroup(string name, string? avatar = null)
+ {
+ return new Chat(Guid.NewGuid(), ChatType.Group, name, avatar);
+ }
+
+ ///
+ /// Фабричный метод для создания чата.
+ ///
+ public static Chat Create(string? name, ChatType type, string? avatar = null)
+ {
+ return new Chat(Guid.NewGuid(), type, name, avatar);
+ }
+
+ public void AddMember(Guid userId, string role = "member")
+ {
+ if (_members.Any(m => m.UserId == userId)) return;
+ _members.Add(new ChatMember(Id, userId, role));
+ }
+
+ 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 UpdateAvatar(string? avatarUrl) => Avatar = avatarUrl;
+}
+
+///
+/// Участник чата.
+///
+public sealed class ChatMember : Entity
+{
+ 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 IsMuted { 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;
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Domain/IChatRepository.cs b/apps/server-net/src/Modules/Chats/Domain/IChatRepository.cs
new file mode 100644
index 0000000..ab3cf62
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Domain/IChatRepository.cs
@@ -0,0 +1,13 @@
+using Vortex.Modules.Chats.Domain;
+
+namespace Vortex.Modules.Chats.Domain;
+
+public interface IChatRepository
+{
+ void Add(Chat chat);
+ void Update(Chat chat);
+ void Remove(Chat chat);
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken);
+ Task GetFavoritesAsync(Guid userId, CancellationToken cancellationToken);
+ Task> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken);
+}
diff --git a/apps/server-net/src/Modules/Chats/Domain/IMessageRepository.cs b/apps/server-net/src/Modules/Chats/Domain/IMessageRepository.cs
new file mode 100644
index 0000000..237e929
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Domain/IMessageRepository.cs
@@ -0,0 +1,12 @@
+using Vortex.Modules.Chats.Domain;
+
+namespace Vortex.Modules.Chats.Domain;
+
+public interface IMessageRepository
+{
+ void Add(Message message);
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken);
+ Task> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
+ Task> SearchMessagesAsync(string query, Guid? chatId, CancellationToken cancellationToken);
+ Task AddReadReceiptsAsync(Guid userId, List messageIds, CancellationToken cancellationToken);
+}
diff --git a/apps/server-net/src/Modules/Chats/Domain/Message.cs b/apps/server-net/src/Modules/Chats/Domain/Message.cs
new file mode 100644
index 0000000..b079f05
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Domain/Message.cs
@@ -0,0 +1,84 @@
+using Vortex.Shared.Kernel;
+
+namespace Vortex.Modules.Chats.Domain;
+
+///
+/// Доменное событие: сообщение отправлено.
+///
+public sealed record MessageSentDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : IDomainEvent;
+
+///
+/// Сущность сообщения (Агрегат).
+///
+public sealed class Message : AggregateRoot
+{
+ public Guid ChatId { get; private set; }
+ public Guid SenderId { get; private set; }
+ public string? Content { get; private set; }
+ public string Type { get; private set; } // text, image, video, voice, file
+ public Guid? ReplyToId { get; private set; }
+ public string? Quote { get; private set; }
+ public bool IsEdited { get; private set; }
+ public bool IsDeleted { get; private set; }
+ public DateTime CreatedAt { get; private set; }
+
+ private readonly List _media = new();
+ public IReadOnlyCollection Media => _media.AsReadOnly();
+
+ private readonly List _readBy = new();
+ public IReadOnlyCollection ReadBy => _readBy.AsReadOnly();
+
+ private Message(Guid id, Guid chatId, Guid senderId, string? content, string type) : base(id)
+ {
+ ChatId = chatId;
+ SenderId = senderId;
+ Content = content;
+ Type = type;
+ CreatedAt = DateTime.UtcNow;
+
+ RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content));
+ }
+
+ public static Message Create(Guid chatId, Guid senderId, string? content, string type)
+ {
+ return new Message(Guid.NewGuid(), chatId, senderId, content, type);
+ }
+
+ public void AddMedia(string type, string url, string? filename, long? size)
+ {
+ _media.Add(new Media(Id, type, url, filename, size));
+ }
+
+ public void Edit(string newContent)
+ {
+ Content = newContent;
+ IsEdited = true;
+ }
+
+ public void Delete()
+ {
+ Content = null;
+ IsDeleted = true;
+ }
+}
+
+///
+/// Медиа-файл, прикрепленный к сообщению.
+///
+public sealed class Media : Entity
+{
+ public Guid MessageId { get; private set; }
+ public string Type { get; private set; }
+ public string Url { get; private set; }
+ public string? Filename { get; private set; }
+ public long? Size { get; private set; }
+
+ internal Media(Guid messageId, string type, string url, string? filename, long? size) : base(Guid.NewGuid())
+ {
+ MessageId = messageId;
+ Type = type;
+ Url = url;
+ Filename = filename;
+ Size = size;
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Domain/ReadReceipt.cs b/apps/server-net/src/Modules/Chats/Domain/ReadReceipt.cs
new file mode 100644
index 0000000..7458980
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Domain/ReadReceipt.cs
@@ -0,0 +1,20 @@
+using Vortex.Shared.Kernel;
+
+namespace Vortex.Modules.Chats.Domain;
+
+public sealed class ReadReceipt : Entity
+{
+ public Guid MessageId { get; private set; }
+ public Guid UserId { get; private set; }
+ public DateTime ReadAt { get; private set; }
+
+ // Для EF Core
+ private ReadReceipt() : base(Guid.Empty) { }
+
+ public ReadReceipt(Guid messageId, Guid userId) : base(Guid.NewGuid())
+ {
+ MessageId = messageId;
+ UserId = userId;
+ ReadAt = DateTime.UtcNow;
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Handlers/MessageSentDomainEventHandler.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Handlers/MessageSentDomainEventHandler.cs
new file mode 100644
index 0000000..1550493
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Infrastructure/Handlers/MessageSentDomainEventHandler.cs
@@ -0,0 +1,55 @@
+using MediatR;
+using Microsoft.AspNetCore.SignalR;
+using Vortex.Modules.Chats.Domain;
+using Vortex.Modules.Chats.Infrastructure.SignalR;
+using Vortex.Shared.Kernel;
+
+namespace Vortex.Modules.Chats.Infrastructure.Handlers;
+
+///
+/// Обработчик доменного события отправки сообщения.
+/// Отправляет уведомление через SignalR всем участникам чата.
+///
+public sealed class MessageSentDomainEventHandler : INotificationHandler
+{
+ private readonly IHubContext _hubContext;
+ private readonly IMessageRepository _messageRepository;
+ private readonly IUserDisplayNameProvider _displayNameProvider;
+
+ public MessageSentDomainEventHandler(
+ IHubContext hubContext,
+ IMessageRepository messageRepository,
+ IUserDisplayNameProvider displayNameProvider)
+ {
+ _hubContext = hubContext;
+ _messageRepository = messageRepository;
+ _displayNameProvider = displayNameProvider;
+ }
+
+ public async Task Handle(MessageSentDomainEvent notification, CancellationToken cancellationToken)
+ {
+ var message = await _messageRepository.GetByIdAsync(notification.MessageId, cancellationToken);
+ if (message is null) return;
+
+ var userInfo = await _displayNameProvider.GetUserInfoAsync(message.SenderId, cancellationToken);
+ var senderObj = userInfo != null
+ ? new { Id = userInfo.Id, Username = userInfo.Username, DisplayName = userInfo.DisplayName, Avatar = userInfo.Avatar }
+ : new { Id = message.SenderId, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null };
+
+ // Отправляем сообщение в "комнату" чата.
+ await _hubContext.Clients.Group(notification.ChatId.ToString())
+ .SendAsync("new_message", new
+ {
+ message.Id,
+ message.ChatId,
+ message.SenderId,
+ message.Content,
+ message.Type,
+ message.CreatedAt,
+ Media = message.Media.ToList(),
+ Sender = senderObj,
+ Reactions = new List