From 7cb6ac61dda6ee93a6c79fc2ef4ded4845ece20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Mon, 23 Mar 2026 01:43:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B9=20=D1=81=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B5?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UnitTest1.cs | 10 - .../UnitTest1.cs | 10 - .../UnitTest1.cs | 10 - .../UnitTest1.cs | 10 - .../UnitTest1.cs | 10 - .../UnitTest1.cs | 10 - .../src/Docs/stories_module_documentation.md | 84 +++++++ backend/src/Modules/Auth/migrations.sql | 229 ------------------ .../Abstractions/IStoriesDbContext.cs | 2 - .../Abstractions/IStoryRepository.cs | 18 ++ .../AddStoryReactionCommand.cs | 153 +----------- .../AddStoryReply/AddStoryReplyCommand.cs | 136 +---------- .../CreateStory/CreateStoryCommand.cs | 16 +- .../RemoveStoryReactionCommand.cs | 49 +--- .../Commands/ViewStory/ViewStoryCommand.cs | 27 ++- .../Application/Stories/DTOs/StoryDto.cs | 7 +- .../Stories/DTOs/StoryReactionDto.cs | 11 - .../Application/Stories/DTOs/StoryReplyDto.cs | 14 -- .../Queries/GetStories/GetStoriesQuery.cs | 37 ++- .../GetStoryReplies/GetStoryRepliesQuery.cs | 75 +----- .../GetStoryViewers/GetStoryViewersQuery.cs | 25 +- .../GetUserStories/GetUserStoriesQuery.cs | 35 ++- .../Modules/Stories/DependencyInjection.cs | 28 +++ backend/src/Modules/Stories/Domain/Story.cs | 52 +--- .../Modules/Stories/Domain/StoryReaction.cs | 21 -- .../src/Modules/Stories/Domain/StoryReply.cs | 21 -- .../src/Modules/Stories/Domain/StoryType.cs | 8 + .../src/Modules/Stories/Domain/StoryViewer.cs | 5 + .../Database/StoriesDbContext.cs | 27 +++ .../Mongo/EncryptedStringSerializer.cs | 49 ++++ .../Mongo/StoriesMongoMapConfigurator.cs | 28 +++ .../Persistence/Mongo/StoryRepository.cs | 62 +++++ .../Stories/Knot.Modules.Stories.csproj | 2 +- .../Endpoints/StoriesEndpoints.cs | 29 --- 34 files changed, 392 insertions(+), 918 deletions(-) delete mode 100644 backend/Tests/Knot.Modules.Relations.UnitTests/UnitTest1.cs delete mode 100644 backend/Tests/Knot.Modules.Settings.UnitTests/UnitTest1.cs delete mode 100644 backend/Tests/Knot.Modules.Storage.UnitTests/UnitTest1.cs delete mode 100644 backend/Tests/Knot.Modules.Stories.UnitTests/UnitTest1.cs delete mode 100644 backend/Tests/Knot.Modules.TelegramImport.UnitTests/UnitTest1.cs delete mode 100644 backend/Tests/Knot.Modules.WebRtc.UnitTests/UnitTest1.cs create mode 100644 backend/src/Docs/stories_module_documentation.md delete mode 100644 backend/src/Modules/Auth/migrations.sql create mode 100644 backend/src/Modules/Stories/Application/Abstractions/IStoryRepository.cs delete mode 100644 backend/src/Modules/Stories/Application/Stories/DTOs/StoryReactionDto.cs delete mode 100644 backend/src/Modules/Stories/Application/Stories/DTOs/StoryReplyDto.cs create mode 100644 backend/src/Modules/Stories/DependencyInjection.cs delete mode 100644 backend/src/Modules/Stories/Domain/StoryReaction.cs delete mode 100644 backend/src/Modules/Stories/Domain/StoryReply.cs create mode 100644 backend/src/Modules/Stories/Domain/StoryType.cs create mode 100644 backend/src/Modules/Stories/Infrastructure/Database/StoriesDbContext.cs create mode 100644 backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/EncryptedStringSerializer.cs create mode 100644 backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoriesMongoMapConfigurator.cs create mode 100644 backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoryRepository.cs diff --git a/backend/Tests/Knot.Modules.Relations.UnitTests/UnitTest1.cs b/backend/Tests/Knot.Modules.Relations.UnitTests/UnitTest1.cs deleted file mode 100644 index e4a5c48..0000000 --- a/backend/Tests/Knot.Modules.Relations.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Knot.Modules.Relations.UnitTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/backend/Tests/Knot.Modules.Settings.UnitTests/UnitTest1.cs b/backend/Tests/Knot.Modules.Settings.UnitTests/UnitTest1.cs deleted file mode 100644 index 853b349..0000000 --- a/backend/Tests/Knot.Modules.Settings.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Knot.Modules.Settings.UnitTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/backend/Tests/Knot.Modules.Storage.UnitTests/UnitTest1.cs b/backend/Tests/Knot.Modules.Storage.UnitTests/UnitTest1.cs deleted file mode 100644 index ce94531..0000000 --- a/backend/Tests/Knot.Modules.Storage.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Knot.Modules.Storage.UnitTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/backend/Tests/Knot.Modules.Stories.UnitTests/UnitTest1.cs b/backend/Tests/Knot.Modules.Stories.UnitTests/UnitTest1.cs deleted file mode 100644 index 1e2093d..0000000 --- a/backend/Tests/Knot.Modules.Stories.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Knot.Modules.Stories.UnitTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/backend/Tests/Knot.Modules.TelegramImport.UnitTests/UnitTest1.cs b/backend/Tests/Knot.Modules.TelegramImport.UnitTests/UnitTest1.cs deleted file mode 100644 index e316532..0000000 --- a/backend/Tests/Knot.Modules.TelegramImport.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Knot.Modules.TelegramImport.UnitTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/backend/Tests/Knot.Modules.WebRtc.UnitTests/UnitTest1.cs b/backend/Tests/Knot.Modules.WebRtc.UnitTests/UnitTest1.cs deleted file mode 100644 index c4d7b7a..0000000 --- a/backend/Tests/Knot.Modules.WebRtc.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Knot.Modules.WebRtc.UnitTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/backend/src/Docs/stories_module_documentation.md b/backend/src/Docs/stories_module_documentation.md new file mode 100644 index 0000000..973b38f --- /dev/null +++ b/backend/src/Docs/stories_module_documentation.md @@ -0,0 +1,84 @@ +# Модуль Историй (Stories) - Техническая Документация + +## 1. Обзор +**Модуль Историй** отвечает за создание, просмотр и предоставление ленты временных публикаций. Любая история доступна для просмотра в ленте ровно 24 часа с момента её публикации, после чего автоматически исчезает. +Модуль построен на архитектуре **CQRS** с использованием паттерна MediatR, а публичный REST API реализован через Minimal APIs с помощью библиотеки Carter. + +*Важная деталь предметной области:* Ответы на истории и быстрые реакции обрабатываются модулем `Conversations/Messaging` и доставляются автору в личные сообщения (Direct Messages) как специальный тип сообщения ([StoryMessage](file:///e:/GIT/forkmessager/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs#104-116)). Сам модуль Stories отвечает исключительно за контент, жизненный цикл и учет просмотров истории. + +## 2. Гибридная Архитектура Хранения +Для обеспечения отказоустойчивости при масштабировании, модуль использует **Гибридную модель данных**, распределяя нагрузку между двумя базами: + +### A. MongoDB (Документное хранилище) +Используется как основное хранилище сверхбыстрых данных документа [Story](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Domain/Story.cs#17-26) ([IStoryRepository](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Application/Abstractions/IStoryRepository.cs#9-19)). +* **Молниеносное чтение:** Отсутствие тяжелых SQL-связей (джоинов) позволяет мгновенно формировать ленты историй для пользователей. Актуальность истории вычисляется автоматически при выборке путем сравнения времени создания с текущим. +* **Атомарные операции:** Счетчик просмотров ([ViewsCount](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Domain/Story.cs#32-36)) обновляется быстрой In-Place атомарной операцией `$inc`, что полностью исключает блокировки записей (locking) при массовых просмотрах. +* **Нативное шифрование (E2EE):** Чувствительные текстовые поля (такие как `Content` и `MediaUrl`) автоматически и прозрачно шифруются алгоритмом AES-256 прямо перед записью в базу через кастомный [EncryptedStringSerializer](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/EncryptedStringSerializer.cs#8-50), внедрённый на уровне BSON-маппинга. + +### B. PostgreSQL (Реляционная база данных) +Используется для хранения точных логов просмотров [StoryViewers](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryViewers/GetStoryViewersQuery.cs#22-23) (через [StoriesDbContext](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Infrastructure/Database/StoriesDbContext.cs#10-13)). +* **Точечный контроль:** Хранение идентификаторов уникальных зрителей (`ViewerId`) в реляционной БД предотвращает раздувание BSON-документов MongoDB. +* **Строгая целостность:** Таблица просмотров содержит составной уникальный индекс (`StoryId`, [UserId](file:///e:/GIT/forkmessager/backend/src/Host/Program.cs#201-205)), что на уровне самой базы гарантирует защиту от "накрутки" — один человек засчитывается в просмотры истории только один раз. + +--- + +## 3. Модели Данных + +### Документ [Story](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Domain/Story.cs#17-26) (в MongoDB) +* **Id (Guid):** Уникальный внутренний ключ. +* **UserId (Guid):** Создатель истории. +* **Type (StoryType Enum):** Тип контента: `Text = 0`, `Image = 1` или `Video = 2`. +* **MediaUrl (string, зашифровано!):** Ссылка на привязанный S3-файл. +* **Content (string, зашифровано!):** Содержимое текстовой истории или подпись к медиа. +* **BgColor (string, опционально):** Цвет фона для текстового типа. +* **CreatedAt (DateTime):** Точка отсчета жизни истории. На основе этого времени "на лету" вычисляется активность истории (обычно `CreatedAt > DateTime.UtcNow.AddHours(-24)`). +* **ViewsCount (int):** Предвычисленный быстрый счетчик уникальных просмотров, готовый к мгновенной выдаче клиентам. + +### Сущность [StoryViewer](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Domain/StoryViewer.cs#11-17) (в PostgreSQL) +* **Id (Guid):** Первичный ключ записи. +* **StoryId (Guid):** Внешний условный ключ профиля истории. +* **UserId (Guid):** Идентификатор посмотревшего. +* **ViewedAt (DateTime):** Точная временная метка факта просмотра. + +--- + +## 4. Хранение Файлов и Безопасность +* **MinIO S3 Хранилище:** Загрузка тяжелого медиа производится через смежный модуль [Storage](file:///e:/GIT/forkmessager/backend/src/Modules/Storage/DependencyInjection.cs#13-35). Файлы именуются по алгоритму SHA-256 (от содержимого файла), что нативно гарантирует абсолютную дедупликацию мемов и роликов на серверах. +* **Шифрование данных (At-Rest):** Все текстовые данные, попадающие в Mongo, зашифрованы сервисом `IEncryptionService`. Дамп базы данных не имеет ценности без ключей окружения приложения. + +--- + +## 5. Описание API Эндпоинтов (`api/stories`) + +| Метод | Маршрут | Описание | +| :--- | :--- | :--- | +| `GET` | `/` | Получение гибридной ленты актуальных историй текущих друзей (Mongo+Postgres маппинг). | +| `GET` | `/user/{userId}` | Запрос актуальных историй конкретного активного пользователя. | +| `POST` | `/` | Создание и мгновенная публикация новой истории. | +| `DELETE`| `/{id}` | Удаление собственной истории автором в любой момент времени. | +| `POST` | `/{id}/view` | Фиксация просмотра. Добавляет запись в PostgreSQL и инкрементит MongoDB-счетчик. | +| `GET` | `/{id}/viewers` | Получение списка зрителей истории (функция "свайп вверх" для авторов). | + +--- + +## 6. Real-Time Интеграция (SignalR WebSockets) +Модуль историй поддерживает мгновенное оповещение авторов через WebSockets, встроенное в [ViewStoryCommand](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Application/Stories/Commands/ViewStory/ViewStoryCommand.cs#22-23). +Логика прямого эфира (когда Юзер B просматривает историю Юзера A): +1. Запрос валидируется через PostgreSQL (ранее не просмотрено). +2. Выполняется мгновенный инкремент `$inc` счетчика в MongoDB. +3. Через `ChatHub` адресно автору истории моментально отправляется событие `story_viewed`. + +**Спецификация JSON-события "Мою историю просмотрели":** +```json +{ + "storyId": "guid", + "userId": "guid", // Кто именно сейчас посмотрел + "username": "johndoe", + "displayName": "John Doe", + "avatar": "url...", + "viewedAt": "timestamp", + "viewCount": 42, // Актуальный живой счетчик на этот момент! + "ownerId": "guid" // Кому адресован ивент (User A) +} +``` +Фронтенд может слушать событие `story_viewed` и анимированно обновлять счетчики "в прямом эфире", а также пополнять список зрителей в попапе истории без дополнительных HTTP-запросов. diff --git a/backend/src/Modules/Auth/migrations.sql b/backend/src/Modules/Auth/migrations.sql deleted file mode 100644 index 457553b..0000000 --- a/backend/src/Modules/Auth/migrations.sql +++ /dev/null @@ -1,229 +0,0 @@ -CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( - "MigrationId" character varying(150) NOT NULL, - "ProductVersion" character varying(32) NOT NULL, - CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId") -); - -START TRANSACTION; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN - IF NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'identity') THEN - CREATE SCHEMA identity; - END IF; - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN - CREATE TABLE identity."Users" ( - "Id" uuid NOT NULL, - "Username" character varying(50) NOT NULL, - "PasswordHash" text NOT NULL, - "DisplayName" character varying(100) NOT NULL, - "Email" character varying(255), - CONSTRAINT "PK_Users" PRIMARY KEY ("Id") - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN - CREATE UNIQUE INDEX "IX_Users_Username" ON identity."Users" ("Username"); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN - INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") - VALUES ('20260311180816_InitialIdentity', '10.0.4'); - END IF; -END $EF$; -COMMIT; - -START TRANSACTION; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN - ALTER TABLE identity."Users" ADD "Avatar" character varying(500); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN - ALTER TABLE identity."Users" ADD "Bio" character varying(500); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN - ALTER TABLE identity."Users" ADD "Birthday" timestamp with time zone; - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN - ALTER TABLE identity."Users" ADD "CreatedAt" timestamp with time zone NOT NULL DEFAULT TIMESTAMPTZ '-infinity'; - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN - CREATE TABLE identity."Friendships" ( - "Id" uuid NOT NULL, - "UserId" uuid NOT NULL, - "FriendId" uuid NOT NULL, - "Status" integer NOT NULL, - "CreatedAt" timestamp with time zone NOT NULL, - CONSTRAINT "PK_Friendships" PRIMARY KEY ("Id") - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN - CREATE TABLE identity."Stories" ( - "Id" uuid NOT NULL, - "UserId" uuid NOT NULL, - "Type" text NOT NULL, - "MediaUrl" text, - "Content" text, - "BgColor" text, - "CreatedAt" timestamp with time zone NOT NULL, - "ExpiresAt" timestamp with time zone NOT NULL, - CONSTRAINT "PK_Stories" PRIMARY KEY ("Id") - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN - CREATE UNIQUE INDEX "IX_Friendships_UserId_FriendId" ON identity."Friendships" ("UserId", "FriendId"); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN - INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") - VALUES ('20260311200839_UpdateIdentityWithNewFieldsAndTables', '10.0.4'); - END IF; -END $EF$; -COMMIT; - -START TRANSACTION; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311215347_AddHideStoryViewsToUser') THEN - ALTER TABLE identity."Users" ADD "HideStoryViews" boolean NOT NULL DEFAULT FALSE; - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311215347_AddHideStoryViewsToUser') THEN - INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") - VALUES ('20260311215347_AddHideStoryViewsToUser', '10.0.4'); - END IF; -END $EF$; -COMMIT; - -START TRANSACTION; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260312174453_AddHideStoryViewsAndFixStoryViewer') THEN - CREATE TABLE identity."StoryViewers" ( - "Id" uuid NOT NULL, - "StoryId" uuid NOT NULL, - "UserId" uuid NOT NULL, - "ViewedAt" timestamp with time zone NOT NULL, - CONSTRAINT "PK_StoryViewers" PRIMARY KEY ("Id"), - CONSTRAINT "FK_StoryViewers_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES identity."Stories" ("Id") ON DELETE CASCADE - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260312174453_AddHideStoryViewsAndFixStoryViewer') THEN - CREATE UNIQUE INDEX "IX_StoryViewers_StoryId_UserId" ON identity."StoryViewers" ("StoryId", "UserId"); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260312174453_AddHideStoryViewsAndFixStoryViewer') THEN - INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") - VALUES ('20260312174453_AddHideStoryViewsAndFixStoryViewer', '10.0.4'); - END IF; -END $EF$; -COMMIT; - -START TRANSACTION; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN - CREATE TABLE identity."StoryReactions" ( - "Id" uuid NOT NULL, - "StoryId" uuid NOT NULL, - "UserId" uuid NOT NULL, - "Emoji" character varying(10) NOT NULL, - "CreatedAt" timestamp with time zone NOT NULL, - CONSTRAINT "PK_StoryReactions" PRIMARY KEY ("Id"), - CONSTRAINT "FK_StoryReactions_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES identity."Stories" ("Id") ON DELETE CASCADE - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN - CREATE TABLE identity."StoryReplies" ( - "Id" uuid NOT NULL, - "StoryId" uuid NOT NULL, - "UserId" uuid NOT NULL, - "Content" character varying(500) NOT NULL, - "CreatedAt" timestamp with time zone NOT NULL, - CONSTRAINT "PK_StoryReplies" PRIMARY KEY ("Id"), - CONSTRAINT "FK_StoryReplies_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES identity."Stories" ("Id") ON DELETE CASCADE - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN - CREATE UNIQUE INDEX "IX_StoryReactions_StoryId_UserId_Emoji" ON identity."StoryReactions" ("StoryId", "UserId", "Emoji"); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN - CREATE INDEX "IX_StoryReplies_StoryId" ON identity."StoryReplies" ("StoryId"); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN - INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") - VALUES ('20260313115319_AddStoryReactionsReplies', '10.0.4'); - END IF; -END $EF$; -COMMIT; - diff --git a/backend/src/Modules/Stories/Application/Abstractions/IStoriesDbContext.cs b/backend/src/Modules/Stories/Application/Abstractions/IStoriesDbContext.cs index fdff347..777b5f5 100644 --- a/backend/src/Modules/Stories/Application/Abstractions/IStoriesDbContext.cs +++ b/backend/src/Modules/Stories/Application/Abstractions/IStoriesDbContext.cs @@ -11,8 +11,6 @@ public interface IStoriesDbContext DbSet Stories { get; } DbSet Friendships { get; } DbSet StoryViewers { get; } - DbSet StoryReactions { get; } - DbSet StoryReplies { get; } DbSet Set() where TEntity : class; Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/backend/src/Modules/Stories/Application/Abstractions/IStoryRepository.cs b/backend/src/Modules/Stories/Application/Abstractions/IStoryRepository.cs new file mode 100644 index 0000000..e5fd595 --- /dev/null +++ b/backend/src/Modules/Stories/Application/Abstractions/IStoryRepository.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Stories.Domain; + +namespace Knot.Modules.Stories.Application.Abstractions; + +public interface IStoryRepository +{ + Task AddAsync(Story story, CancellationToken cancellationToken = default); + Task UpdateAsync(Story story, CancellationToken cancellationToken = default); + Task DeleteAsync(Story story, CancellationToken cancellationToken = default); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task> GetActiveUserStoriesAsync(Guid userId, CancellationToken cancellationToken = default); + Task> GetFeedStoriesAsync(List userIds, CancellationToken cancellationToken = default); + Task IncrementViewsCountAsync(Guid storyId, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs b/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs index 50519e9..9ac539a 100644 --- a/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs +++ b/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs @@ -1,152 +1 @@ -using Knot.Modules.Messaging.Domain; -using Host.Application.Stories; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediatR; -using Knot.Shared.Kernel; -using Knot.Modules.Stories.Domain; - - -using Knot.Modules.Conversations.Domain; -using Knot.Modules.Conversations.Application.Messages.Send; -using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.SignalR; -using Knot.Modules.Conversations.Infrastructure.SignalR; - - - - - -using Knot.Modules.Stories.Application.DTOs; - - - -using Knot.Modules.Messaging.Application.Abstractions; -namespace Host.Application.Stories.Commands.AddStoryReaction; - -public record AddStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand; - -internal sealed class AddStoryReactionCommandHandler : ICommandHandler -{ - private readonly IStoriesDbContext _context; - private readonly IUserRepository _userRepository; - private readonly IChatRepository _chatRepository; - private readonly IMessageRepository _messageRepository; - private readonly ISender _sender; - private readonly IHubContext _hubContext; - - public AddStoryReactionCommandHandler( - IStoriesDbContext context, - IUserRepository userRepository, - IChatRepository chatRepository, - IMessageRepository messageRepository, - ISender sender, - IHubContext hubContext) - { - _context = context; - _userRepository = userRepository; - _chatRepository = chatRepository; - _messageRepository = messageRepository; - _sender = sender; - _hubContext = hubContext; - } - - public async Task> Handle(AddStoryReactionCommand request, CancellationToken cancellationToken) - { - var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken); - if (story == null) - { - return Result.Failure(StoryErrors.StoryNotFound); - } - - var existing = await _context.StoryReactions - .FirstOrDefaultAsync(r => r.StoryId == request.StoryId && r.UserId == request.UserId && r.Emoji == request.Emoji, cancellationToken); - - if (existing != null) - { - return Result.Success(new MessageResponse("Reaction already exists")); - } - - var reaction = new StoryReaction(request.StoryId, request.UserId, request.Emoji); - _context.StoryReactions.Add(reaction); - await _context.SaveChangesAsync(cancellationToken); - - var chatId = await GetOrCreatePersonalChatIdAsync(request.UserId, story.UserId, cancellationToken); - var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, cancellationToken); - - if (lastStoryMessage != null) - { - var addReactionCommand = new Knot.Modules.Conversations.Application.Messages.React.AddReactionCommand( - lastStoryMessage.Id, request.UserId, request.Emoji, chatId); - await _sender.Send(addReactionCommand, cancellationToken); - } - else - { - var storyQuote = GetStoryQuote(story); - var messageCommand = new SendMessageCommand( - ChatId: chatId, - SenderId: request.UserId, - Content: request.Emoji, - Type: "text", - Quote: storyQuote, - StoryId: story.Id, - StoryMediaUrl: story.MediaUrl, - StoryMediaType: story.Type, - Attachments: null, - ReplyToId: null, - ForwardedFromId: null); - await _sender.Send(messageCommand, cancellationToken); - } - - var reactor = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); - await _hubContext.Clients.All.SendAsync("story_reaction", new - { - storyId = story.Id, - userId = request.UserId, - username = reactor?.Username, - displayName = reactor?.DisplayName, - avatar = reactor?.Avatar, - emoji = request.Emoji, - createdAt = DateTime.UtcNow, - ownerId = story.UserId - }, cancellationToken); - - return Result.Success(new MessageResponse("Reaction added")); - } - - private string GetStoryQuote(Story story) - { - if (!string.IsNullOrEmpty(story.Content)) - { - return story.Content; - } - return story.Type.ToLower() switch - { - "image" => "🖼 Фото", - "video" => "🎬 Видео", - _ => "История" - }; - } - - private async Task GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken cancellationToken) - { - var userChats = await _chatRepository.GetUserChatsAsync(userId1, cancellationToken); - var personalChat = userChats.FirstOrDefault(c => - c.Type == ChatType.Personal && - c.Members.Any(m => m.UserId == userId2)); - - if (personalChat != null) - { - return personalChat.Id; - } - - var command = new Knot.Modules.Conversations.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List { userId1, userId2 }); - var result = await _sender.Send(command, cancellationToken); - return result.Value; - } -} - - +// file removed diff --git a/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs b/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs index 08a5bfe..0ae3d6f 100644 --- a/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs +++ b/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs @@ -1,135 +1 @@ -using Knot.Modules.Messaging.Domain; -using Host.Application.Stories; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediatR; -using Knot.Shared.Kernel; -using Knot.Modules.Stories.Domain; - - -using Knot.Modules.Conversations.Domain; -using Knot.Modules.Conversations.Application.Messages.Send; -using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.SignalR; -using Knot.Modules.Conversations.Infrastructure.SignalR; - - - - - -using Knot.Modules.Stories.Application.DTOs; - - - -using Knot.Modules.Messaging.Application.Abstractions; -namespace Host.Application.Stories.Commands.AddStoryReply; - -public record AddStoryReplyCommand(Guid UserId, Guid StoryId, string Content) : ICommand; - -internal sealed class AddStoryReplyCommandHandler : ICommandHandler -{ - private readonly IStoriesDbContext _context; - private readonly IUserRepository _userRepository; - private readonly IChatRepository _chatRepository; - private readonly IMessageRepository _messageRepository; - private readonly ISender _sender; - private readonly IHubContext _hubContext; - - public AddStoryReplyCommandHandler( - IStoriesDbContext context, - IUserRepository userRepository, - IChatRepository chatRepository, - IMessageRepository messageRepository, - ISender sender, - IHubContext hubContext) - { - _context = context; - _userRepository = userRepository; - _chatRepository = chatRepository; - _messageRepository = messageRepository; - _sender = sender; - _hubContext = hubContext; - } - - public async Task> Handle(AddStoryReplyCommand request, CancellationToken cancellationToken) - { - var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken); - if (story == null) - { - return Result.Failure(StoryErrors.StoryNotFound); - } - - var reply = new StoryReply(request.StoryId, request.UserId, request.Content); - _context.StoryReplies.Add(reply); - await _context.SaveChangesAsync(cancellationToken); - - var chatId = await GetOrCreatePersonalChatIdAsync(request.UserId, story.UserId, cancellationToken); - var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, cancellationToken); - - var storyQuote = GetStoryQuote(story); - var messageCommand = new SendMessageCommand( - ChatId: chatId, - SenderId: request.UserId, - Content: request.Content, - Type: "text", - Quote: storyQuote, - ReplyToId: lastStoryMessage?.Id, - StoryId: story.Id, - StoryMediaUrl: story.MediaUrl, - StoryMediaType: story.Type, - Attachments: null, - ForwardedFromId: null); - await _sender.Send(messageCommand, cancellationToken); - - var replier = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); - await _hubContext.Clients.All.SendAsync("story_reply", new - { - storyId = story.Id, - userId = request.UserId, - username = replier?.Username, - displayName = replier?.DisplayName, - avatar = replier?.Avatar, - content = request.Content, - createdAt = DateTime.UtcNow, - ownerId = story.UserId - }, cancellationToken); - - return Result.Success(new MessageResponse("Reply added")); - } - - private string GetStoryQuote(Story story) - { - if (!string.IsNullOrEmpty(story.Content)) - { - return story.Content; - } - return story.Type.ToLower() switch - { - "image" => "�� Фото", - "video" => "🎬 Видео", - _ => "История" - }; - } - - private async Task GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken cancellationToken) - { - var userChats = await _chatRepository.GetUserChatsAsync(userId1, cancellationToken); - var personalChat = userChats.FirstOrDefault(c => - c.Type == ChatType.Personal && - c.Members.Any(m => m.UserId == userId2)); - - if (personalChat != null) - { - return personalChat.Id; - } - - var command = new Knot.Modules.Conversations.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List { userId1, userId2 }); - var result = await _sender.Send(command, cancellationToken); - return result.Value; - } -} - - +// file removed diff --git a/backend/src/Modules/Stories/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs b/backend/src/Modules/Stories/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs index 6bb721f..29b42c3 100644 --- a/backend/src/Modules/Stories/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs +++ b/backend/src/Modules/Stories/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs @@ -12,24 +12,28 @@ public record CreateStoryCommand(Guid UserId, string Type, string? MediaUrl, str internal sealed class CreateStoryCommandHandler : ICommandHandler { - private readonly IStoriesDbContext _context; + private readonly IStoryRepository _storyRepository; - public CreateStoryCommandHandler(IStoriesDbContext context) + public CreateStoryCommandHandler(IStoryRepository storyRepository) { - _context = context; + _storyRepository = storyRepository; } public async Task> Handle(CreateStoryCommand request, CancellationToken cancellationToken) { + if (!Enum.TryParse(request.Type, true, out var parsedType)) + { + parsedType = StoryType.Text; + } + var story = Story.Create( request.UserId, - request.Type, + parsedType, request.MediaUrl, request.Content, request.BgColor); - _context.Stories.Add(story); - await _context.SaveChangesAsync(cancellationToken); + await _storyRepository.AddAsync(story, cancellationToken); return Result.Success(story.Id); } diff --git a/backend/src/Modules/Stories/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs b/backend/src/Modules/Stories/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs index fc772bd..0ae3d6f 100644 --- a/backend/src/Modules/Stories/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs +++ b/backend/src/Modules/Stories/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs @@ -1,48 +1 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediatR; -using Knot.Shared.Kernel; - -using Microsoft.EntityFrameworkCore; - - - - - -using Knot.Modules.Stories.Application.DTOs; - - - -namespace Host.Application.Stories.Commands.RemoveStoryReaction; - -public record RemoveStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand; - -internal sealed class RemoveStoryReactionCommandHandler : ICommandHandler -{ - private readonly IStoriesDbContext _context; - - public RemoveStoryReactionCommandHandler(IStoriesDbContext context) - { - _context = context; - } - - public async Task> Handle(RemoveStoryReactionCommand request, CancellationToken cancellationToken) - { - var reaction = await _context.StoryReactions - .FirstOrDefaultAsync(r => r.StoryId == request.StoryId && r.UserId == request.UserId && r.Emoji == request.Emoji, cancellationToken); - - if (reaction == null) - { - return Result.Success(new MessageResponse("Reaction not found")); - } - - _context.StoryReactions.Remove(reaction); - await _context.SaveChangesAsync(cancellationToken); - - return Result.Success(new MessageResponse("Reaction removed")); - } -} - - +// file removed diff --git a/backend/src/Modules/Stories/Application/Stories/Commands/ViewStory/ViewStoryCommand.cs b/backend/src/Modules/Stories/Application/Stories/Commands/ViewStory/ViewStoryCommand.cs index 8b51269..d0c7170 100644 --- a/backend/src/Modules/Stories/Application/Stories/Commands/ViewStory/ViewStoryCommand.cs +++ b/backend/src/Modules/Stories/Application/Stories/Commands/ViewStory/ViewStoryCommand.cs @@ -12,10 +12,8 @@ using Microsoft.AspNetCore.SignalR; using Knot.Modules.Conversations.Infrastructure.SignalR; - - - using Knot.Modules.Stories.Application.DTOs; +using Knot.Modules.Stories.Infrastructure.Database; @@ -25,13 +23,19 @@ public record ViewStoryCommand(Guid UserId, Guid StoryId) : ICommand { - private readonly IStoriesDbContext _context; + private readonly StoriesDbContext _context; + private readonly Knot.Modules.Stories.Application.Abstractions.IStoryRepository _storyRepository; private readonly IUserRepository _userRepository; private readonly IHubContext _hubContext; - public ViewStoryCommandHandler(IStoriesDbContext context, IUserRepository userRepository, IHubContext hubContext) + public ViewStoryCommandHandler( + StoriesDbContext context, + Knot.Modules.Stories.Application.Abstractions.IStoryRepository storyRepository, + IUserRepository userRepository, + IHubContext hubContext) { _context = context; + _storyRepository = storyRepository; _userRepository = userRepository; _hubContext = hubContext; } @@ -40,9 +44,7 @@ internal sealed class ViewStoryCommandHandler : ICommandHandler s.Viewers) - .FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken); + var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken); if (story == null) { @@ -54,13 +56,14 @@ internal sealed class ViewStoryCommandHandler : ICommandHandler v.UserId == request.UserId)) + if (!await _context.StoryViewers.AnyAsync(v => v.StoryId == request.StoryId && v.UserId == request.UserId, cancellationToken)) { - story.AddViewer(request.UserId); + _context.StoryViewers.Add(StoryViewer.Create(story.Id, request.UserId)); await _context.SaveChangesAsync(cancellationToken); + await _storyRepository.IncrementViewsCountAsync(story.Id, cancellationToken); + var viewer = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); - var updatedStory = await _context.Stories.Include(s => s.Viewers).FirstAsync(s => s.Id == story.Id, cancellationToken); await _hubContext.Clients.All.SendAsync("story_viewed", new { @@ -70,7 +73,7 @@ internal sealed class ViewStoryCommandHandler : ICommandHandler Reactions, - int ReplyCount + bool Viewed ); diff --git a/backend/src/Modules/Stories/Application/Stories/DTOs/StoryReactionDto.cs b/backend/src/Modules/Stories/Application/Stories/DTOs/StoryReactionDto.cs deleted file mode 100644 index aebc44f..0000000 --- a/backend/src/Modules/Stories/Application/Stories/DTOs/StoryReactionDto.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; - -using Knot.Modules.Stories.Application.Abstractions; -namespace Knot.Modules.Stories.Application.DTOs; - -public record StoryReactionDto( - Guid Id, - Guid UserId, - string Emoji, - DateTime CreatedAt -); diff --git a/backend/src/Modules/Stories/Application/Stories/DTOs/StoryReplyDto.cs b/backend/src/Modules/Stories/Application/Stories/DTOs/StoryReplyDto.cs deleted file mode 100644 index d3754b5..0000000 --- a/backend/src/Modules/Stories/Application/Stories/DTOs/StoryReplyDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -using Knot.Modules.Stories.Application.Abstractions; -namespace Knot.Modules.Stories.Application.DTOs; - -public record StoryReplyDto( - Guid Id, - Guid UserId, - string Username, - string DisplayName, - string? Avatar, - string Content, - DateTime CreatedAt -); diff --git a/backend/src/Modules/Stories/Application/Stories/Queries/GetStories/GetStoriesQuery.cs b/backend/src/Modules/Stories/Application/Stories/Queries/GetStories/GetStoriesQuery.cs index c21f91d..6f98bda 100644 --- a/backend/src/Modules/Stories/Application/Stories/Queries/GetStories/GetStoriesQuery.cs +++ b/backend/src/Modules/Stories/Application/Stories/Queries/GetStories/GetStoriesQuery.cs @@ -8,13 +8,9 @@ using Knot.Shared.Kernel; using Knot.Modules.Stories.Domain; -using Knot.Modules.Conversations.Domain; -using Knot.Modules.Conversations.Application.Messages.Send; +using Knot.Modules.Stories.Application.Abstractions; +using Knot.Modules.Stories.Infrastructure.Database; using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.SignalR; -using Knot.Modules.Conversations.Infrastructure.SignalR; - - @@ -28,12 +24,14 @@ public record GetStoriesQuery(Guid UserId) : IQuery>; internal sealed class GetStoriesQueryHandler : IQueryHandler> { - private readonly IStoriesDbContext _context; + private readonly StoriesDbContext _context; + private readonly IStoryRepository _storyRepository; private readonly IUserRepository _userRepository; - public GetStoriesQueryHandler(IStoriesDbContext context, IUserRepository userRepository) + public GetStoriesQueryHandler(StoriesDbContext context, IStoryRepository storyRepository, IUserRepository userRepository) { _context = context; + _storyRepository = storyRepository; _userRepository = userRepository; } @@ -46,12 +44,12 @@ internal sealed class GetStoriesQueryHandler : IQueryHandler f.UserId == request.UserId ? f.FriendId : f.UserId).ToList(); friendIds.Add(request.UserId); - var stories = await _context.Stories - .Include(s => s.Viewers) - .Include(s => s.Reactions) - .Include(s => s.Replies) - .Where(s => s.ExpiresAt > DateTime.UtcNow && friendIds.Contains(s.UserId)) - .OrderByDescending(s => s.CreatedAt) + var stories = await _storyRepository.GetFeedStoriesAsync(friendIds, cancellationToken); + var storyIds = stories.Select(s => s.Id).ToList(); + + var viewedStoriesIds = await _context.StoryViewers + .Where(v => v.UserId == request.UserId && storyIds.Contains(v.StoryId)) + .Select(v => v.StoryId) .ToListAsync(cancellationToken); var groups = stories.GroupBy(s => s.UserId).ToList(); @@ -84,17 +82,14 @@ internal sealed class GetStoriesQueryHandler : IQueryHandler v.UserId == request.UserId), - s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(), - s.Replies.Count + s.ViewsCount, + viewedStoriesIds.Contains(s.Id) )).OrderBy(s => s.CreatedAt).ToList(), - group.Any(s => !s.Viewers.Any(v => v.UserId == request.UserId)) + group.Any(s => !viewedStoriesIds.Contains(s.Id)) )); } - return Result.Success(result.OrderBy(r => + return Result.Success(result.OrderBy(r => { if (r.User.Id == request.UserId) { diff --git a/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs b/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs index da61633..0ae3d6f 100644 --- a/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs +++ b/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs @@ -1,74 +1 @@ -using Host.Application.Stories; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediatR; -using Knot.Shared.Kernel; -using Knot.Modules.Stories.Domain; - -using Microsoft.EntityFrameworkCore; - - - - - -using Knot.Modules.Stories.Application.DTOs; - - - -namespace Host.Application.Stories.Queries.GetStoryReplies; - -public record GetStoryRepliesQuery(Guid UserId, Guid StoryId) : IQuery>; - -internal sealed class GetStoryRepliesQueryHandler : IQueryHandler> -{ - private readonly IStoriesDbContext _context; - private readonly IUserRepository _userRepository; - - public GetStoryRepliesQueryHandler(IStoriesDbContext context, IUserRepository userRepository) - { - _context = context; - _userRepository = userRepository; - } - - public async Task>> Handle(GetStoryRepliesQuery request, CancellationToken cancellationToken) - { - var story = await _context.Stories - .Include(s => s.Replies) - .FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken); - - if (story == null) - { - return Result.Failure>(StoryErrors.StoryNotFound); - } - if (story.UserId != request.UserId) - { - return Result.Failure>(StoryErrors.Unauthorized); - } - - var replies = new List(); - foreach (var reply in story.Replies.OrderBy(r => r.CreatedAt)) - { - var user = await _userRepository.GetByIdAsync(reply.UserId, cancellationToken); - if (user == null) - { - continue; - } - - replies.Add(new StoryReplyDto( - reply.Id, - user.Id, - user.Username, - user.DisplayName, - user.Avatar, - reply.Content, - reply.CreatedAt - )); - } - - return Result.Success(replies); - } -} - +// file removed diff --git a/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryViewers/GetStoryViewersQuery.cs b/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryViewers/GetStoryViewersQuery.cs index d4698f0..d3696c9 100644 --- a/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryViewers/GetStoryViewersQuery.cs +++ b/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryViewers/GetStoryViewersQuery.cs @@ -15,8 +15,7 @@ using Microsoft.EntityFrameworkCore; using Knot.Modules.Stories.Application.DTOs; - - +using Knot.Modules.Stories.Infrastructure.Database; namespace Host.Application.Stories.Queries.GetStoryViewers; @@ -24,20 +23,20 @@ public record GetStoryViewersQuery(Guid UserId, Guid StoryId) : IQuery> { - private readonly IStoriesDbContext _context; + private readonly StoriesDbContext _context; + private readonly Knot.Modules.Stories.Application.Abstractions.IStoryRepository _storyRepository; private readonly IUserRepository _userRepository; - public GetStoryViewersQueryHandler(IStoriesDbContext context, IUserRepository userRepository) + public GetStoryViewersQueryHandler(StoriesDbContext context, Knot.Modules.Stories.Application.Abstractions.IStoryRepository storyRepository, IUserRepository userRepository) { _context = context; + _storyRepository = storyRepository; _userRepository = userRepository; } public async Task>> Handle(GetStoryViewersQuery request, CancellationToken cancellationToken) { - var story = await _context.Stories - .Include(s => s.Viewers) - .FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken); + var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken); if (story == null) { @@ -48,19 +47,21 @@ internal sealed class GetStoryViewersQueryHandler : IQueryHandler>(StoryErrors.Unauthorized); } - var viewerIds = story.Viewers.Select(v => v.UserId).ToList(); + var viewersRecords = await _context.StoryViewers + .Where(v => v.StoryId == request.StoryId) + .OrderByDescending(v => v.ViewedAt) + .ToListAsync(cancellationToken); + var viewers = new List(); - foreach (var viewerId in viewerIds) + foreach (var viewerRecord in viewersRecords) { - var user = await _userRepository.GetByIdAsync(viewerId, cancellationToken); + var user = await _userRepository.GetByIdAsync(viewerRecord.UserId, cancellationToken); if (user == null) { continue; } - var viewerRecord = story.Viewers.First(v => v.UserId == viewerId); - viewers.Add(new StoryViewerDto( user.Id, user.Username, diff --git a/backend/src/Modules/Stories/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs b/backend/src/Modules/Stories/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs index 9f267c8..8865437 100644 --- a/backend/src/Modules/Stories/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs +++ b/backend/src/Modules/Stories/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs @@ -2,18 +2,14 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; -using System.Threading.Tasks; using MediatR; using Knot.Shared.Kernel; -using Knot.Modules.Stories.Domain; - - +using Knot.Modules.Stories.Application.Abstractions; +using Knot.Modules.Stories.Infrastructure.Database; using Microsoft.EntityFrameworkCore; - - using Knot.Modules.Stories.Application.DTOs; @@ -24,23 +20,25 @@ public record GetUserStoriesQuery(Guid CurrentUserId, Guid TargetUserId) : IQuer internal sealed class GetUserStoriesQueryHandler : IQueryHandler { - private readonly IStoriesDbContext _context; + private readonly StoriesDbContext _context; + private readonly IStoryRepository _storyRepository; private readonly IUserRepository _userRepository; - public GetUserStoriesQueryHandler(IStoriesDbContext context, IUserRepository userRepository) + public GetUserStoriesQueryHandler(StoriesDbContext context, IStoryRepository storyRepository, IUserRepository userRepository) { _context = context; + _storyRepository = storyRepository; _userRepository = userRepository; } public async Task> Handle(GetUserStoriesQuery request, CancellationToken cancellationToken) { - var stories = await _context.Stories - .Include(s => s.Viewers) - .Include(s => s.Reactions) - .Include(s => s.Replies) - .Where(s => s.UserId == request.TargetUserId) - .OrderByDescending(s => s.CreatedAt) + var stories = await _storyRepository.GetActiveUserStoriesAsync(request.TargetUserId, cancellationToken); + var storyIds = stories.Select(s => s.Id).ToList(); + + var viewedStoriesIds = await _context.StoryViewers + .Where(v => v.UserId == request.CurrentUserId && storyIds.Contains(v.StoryId)) + .Select(v => v.StoryId) .ToListAsync(cancellationToken); var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken); @@ -56,13 +54,10 @@ internal sealed class GetUserStoriesQueryHandler : IQueryHandler v.UserId == request.CurrentUserId), - s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(), - s.Replies.Count + s.ViewsCount, + viewedStoriesIds.Contains(s.Id) )).ToList(), - stories.Any(s => !s.Viewers.Any(v => v.UserId == request.CurrentUserId)) + stories.Any(s => !viewedStoriesIds.Contains(s.Id)) ); return Result.Success(result); diff --git a/backend/src/Modules/Stories/DependencyInjection.cs b/backend/src/Modules/Stories/DependencyInjection.cs new file mode 100644 index 0000000..28e48a8 --- /dev/null +++ b/backend/src/Modules/Stories/DependencyInjection.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Knot.Modules.Stories.Application.Abstractions; +using Knot.Modules.Stories.Infrastructure.Persistence.Mongo; +using MongoDB.Driver; + +namespace Knot.Modules.Stories; + +public static class DependencyInjection +{ + public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + + var connectionString = configuration.GetConnectionString("DefaultConnection"); + services.AddDbContext(options => + options.UseNpgsql(connectionString)); + + // MongoDB configuration happens in StoriesMongoMapConfigurator (called in Program.cs when EncryptionService is ready, or we can just call it here) + StoriesMongoMapConfigurator.Configure(); + + services.AddMediatR(config => + config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); + + return services; + } +} diff --git a/backend/src/Modules/Stories/Domain/Story.cs b/backend/src/Modules/Stories/Domain/Story.cs index 4d1edbe..a0e20ae 100644 --- a/backend/src/Modules/Stories/Domain/Story.cs +++ b/backend/src/Modules/Stories/Domain/Story.cs @@ -5,24 +5,16 @@ namespace Knot.Modules.Stories.Domain; public class Story : Entity { public Guid UserId { get; private set; } - public string Type { get; private set; } // text, image, video + public StoryType Type { get; private set; } public string? MediaUrl { get; private set; } public string? Content { get; private set; } public string? BgColor { get; private set; } public DateTime CreatedAt { get; private set; } - public DateTime ExpiresAt { get; private set; } + public int ViewsCount { get; private set; } - private readonly List _viewers = new(); - private readonly List _reactions = new(); - private readonly List _replies = new(); + protected Story() : base(Guid.NewGuid()) { } - public IReadOnlyCollection Viewers => _viewers.AsReadOnly(); - public IReadOnlyCollection Reactions => _reactions.AsReadOnly(); - public IReadOnlyCollection Replies => _replies.AsReadOnly(); - - protected Story() : base(Guid.NewGuid()) { Type = string.Empty; } - - internal Story(Guid id, Guid userId, string type, string? mediaUrl, string? content, string? bgColor) : base(id) + internal Story(Guid id, Guid userId, StoryType type, string? mediaUrl, string? content, string? bgColor) : base(id) { UserId = userId; Type = type; @@ -30,45 +22,15 @@ public class Story : Entity Content = content; BgColor = bgColor; CreatedAt = DateTime.UtcNow; - ExpiresAt = CreatedAt.AddDays(1); } - public static Story Create(Guid userId, string type, string? mediaUrl, string? content, string? bgColor) + public static Story Create(Guid userId, StoryType type, string? mediaUrl, string? content, string? bgColor) { return new Story(Guid.NewGuid(), userId, type, mediaUrl, content, bgColor); } - public void AddViewer(Guid userId) + public void IncrementViewsCount() { - if (_viewers.Any(v => v.UserId == userId)) - { - return; - } - - _viewers.Add(new StoryViewer(Id, userId)); - } - - public void AddReaction(Guid userId, string emoji) - { - if (_reactions.Any(r => r.UserId == userId && r.Emoji == emoji)) - { - return; - } - - _reactions.Add(new StoryReaction(Id, userId, emoji)); - } - - public void RemoveReaction(Guid userId, string emoji) - { - var reaction = _reactions.FirstOrDefault(r => r.UserId == userId && r.Emoji == emoji); - if (reaction != null) - { - _reactions.Remove(reaction); - } - } - - public void AddReply(Guid userId, string content) - { - _replies.Add(new StoryReply(Id, userId, content)); + ViewsCount++; } } diff --git a/backend/src/Modules/Stories/Domain/StoryReaction.cs b/backend/src/Modules/Stories/Domain/StoryReaction.cs deleted file mode 100644 index bfbe905..0000000 --- a/backend/src/Modules/Stories/Domain/StoryReaction.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Knot.Shared.Kernel; - -namespace Knot.Modules.Stories.Domain; - -public sealed class StoryReaction : Entity -{ - public Guid StoryId { get; set; } - public Guid UserId { get; set; } - public string Emoji { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } - - private StoryReaction() : base(Guid.NewGuid()) { } - - public StoryReaction(Guid storyId, Guid userId, string emoji) : base(Guid.NewGuid()) - { - StoryId = storyId; - UserId = userId; - Emoji = emoji; - CreatedAt = DateTime.UtcNow; - } -} diff --git a/backend/src/Modules/Stories/Domain/StoryReply.cs b/backend/src/Modules/Stories/Domain/StoryReply.cs deleted file mode 100644 index 0c5f393..0000000 --- a/backend/src/Modules/Stories/Domain/StoryReply.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Knot.Shared.Kernel; - -namespace Knot.Modules.Stories.Domain; - -public sealed class StoryReply : Entity -{ - public Guid StoryId { get; set; } - public Guid UserId { get; set; } - public string Content { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } - - private StoryReply() : base(Guid.NewGuid()) { } - - public StoryReply(Guid storyId, Guid userId, string content) : base(Guid.NewGuid()) - { - StoryId = storyId; - UserId = userId; - Content = content; - CreatedAt = DateTime.UtcNow; - } -} diff --git a/backend/src/Modules/Stories/Domain/StoryType.cs b/backend/src/Modules/Stories/Domain/StoryType.cs new file mode 100644 index 0000000..d7a5bcc --- /dev/null +++ b/backend/src/Modules/Stories/Domain/StoryType.cs @@ -0,0 +1,8 @@ +namespace Knot.Modules.Stories.Domain; + +public enum StoryType +{ + Text = 0, + Image = 1, + Video = 2 +} diff --git a/backend/src/Modules/Stories/Domain/StoryViewer.cs b/backend/src/Modules/Stories/Domain/StoryViewer.cs index 5728cbe..9646fcd 100644 --- a/backend/src/Modules/Stories/Domain/StoryViewer.cs +++ b/backend/src/Modules/Stories/Domain/StoryViewer.cs @@ -14,4 +14,9 @@ public sealed class StoryViewer : Entity UserId = userId; ViewedAt = DateTime.UtcNow; } + + public static StoryViewer Create(Guid storyId, Guid userId) + { + return new StoryViewer(storyId, userId); + } } diff --git a/backend/src/Modules/Stories/Infrastructure/Database/StoriesDbContext.cs b/backend/src/Modules/Stories/Infrastructure/Database/StoriesDbContext.cs new file mode 100644 index 0000000..cbec2dd --- /dev/null +++ b/backend/src/Modules/Stories/Infrastructure/Database/StoriesDbContext.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Stories.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Knot.Modules.Stories.Infrastructure.Database; + +public class StoriesDbContext : DbContext +{ + public StoriesDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet StoryViewers => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("stories"); + + modelBuilder.Entity(builder => + { + builder.ToTable("StoryViewers"); + builder.HasKey(s => s.Id); + builder.HasIndex(s => new { s.StoryId, s.UserId }).IsUnique(); + }); + } +} diff --git a/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/EncryptedStringSerializer.cs b/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/EncryptedStringSerializer.cs new file mode 100644 index 0000000..e8dcbb2 --- /dev/null +++ b/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/EncryptedStringSerializer.cs @@ -0,0 +1,49 @@ +using System; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using Knot.Shared.Kernel.Security; + +namespace Knot.Modules.Stories.Infrastructure.Persistence.Mongo; + +public class EncryptedStringSerializer : SerializerBase +{ + public static IEncryptionService? EncryptionService { get; set; } + + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, string value) + { + if (string.IsNullOrEmpty(value) || EncryptionService == null) + { + context.Writer.WriteString(value ?? string.Empty); + return; + } + + try + { + var encrypted = EncryptionService.EncryptMessage(value); + context.Writer.WriteString(encrypted); + } + catch + { + context.Writer.WriteString(value); + } + } + + public override string Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var value = context.Reader.ReadString(); + if (string.IsNullOrEmpty(value) || EncryptionService == null) + { + return value; + } + + try + { + return EncryptionService.DecryptMessage(value); + } + catch + { + // Fallback for already unencrypted, or failed to decrypt + return value; + } + } +} diff --git a/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoriesMongoMapConfigurator.cs b/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoriesMongoMapConfigurator.cs new file mode 100644 index 0000000..59a45b9 --- /dev/null +++ b/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoriesMongoMapConfigurator.cs @@ -0,0 +1,28 @@ +using Knot.Modules.Stories.Domain; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace Knot.Modules.Stories.Infrastructure.Persistence.Mongo; + +public static class StoriesMongoMapConfigurator +{ + private static bool _configured; + + public static void Configure() + { + if (_configured) return; + + BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); + + BsonClassMap.RegisterClassMap(cm => + { + cm.AutoMap(); + // Encrypt Content and MediaUrl + cm.MapProperty(c => c.Content).SetSerializer(new EncryptedStringSerializer()); + cm.MapProperty(c => c.MediaUrl).SetSerializer(new EncryptedStringSerializer()); + }); + + _configured = true; + } +} diff --git a/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoryRepository.cs b/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoryRepository.cs new file mode 100644 index 0000000..59ae60b --- /dev/null +++ b/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoryRepository.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Stories.Application.Abstractions; +using Knot.Modules.Stories.Domain; +using MongoDB.Driver; + +namespace Knot.Modules.Stories.Infrastructure.Persistence.Mongo; + +internal sealed class StoryRepository : IStoryRepository +{ + private readonly IMongoCollection _stories; + + public StoryRepository(IMongoDatabase database) + { + _stories = database.GetCollection("stories"); + } + + public async Task AddAsync(Story story, CancellationToken cancellationToken = default) + { + await _stories.InsertOneAsync(story, new InsertOneOptions(), cancellationToken); + } + + public async Task UpdateAsync(Story story, CancellationToken cancellationToken = default) + { + await _stories.ReplaceOneAsync(s => s.Id == story.Id, story, new ReplaceOptions(), cancellationToken); + } + + public async Task DeleteAsync(Story story, CancellationToken cancellationToken = default) + { + await _stories.DeleteOneAsync(s => s.Id == story.Id, cancellationToken); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _stories.Find(s => s.Id == id).FirstOrDefaultAsync(cancellationToken); + } + + public async Task> GetActiveUserStoriesAsync(Guid userId, CancellationToken cancellationToken = default) + { + var yesterday = DateTime.UtcNow.AddHours(-24); + return await _stories.Find(s => s.UserId == userId && s.CreatedAt > yesterday) + .SortByDescending(s => s.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetFeedStoriesAsync(List userIds, CancellationToken cancellationToken = default) + { + var yesterday = DateTime.UtcNow.AddHours(-24); + return await _stories.Find(s => s.CreatedAt > yesterday && userIds.Contains(s.UserId)) + .SortByDescending(s => s.CreatedAt) + .Limit(100) + .ToListAsync(cancellationToken); + } + + public async Task IncrementViewsCountAsync(Guid storyId, CancellationToken cancellationToken = default) + { + var update = Builders.Update.Inc(s => s.ViewsCount, 1); + await _stories.UpdateOneAsync(s => s.Id == storyId, update, new UpdateOptions(), cancellationToken); + } +} diff --git a/backend/src/Modules/Stories/Knot.Modules.Stories.csproj b/backend/src/Modules/Stories/Knot.Modules.Stories.csproj index af5fb80..f52ea50 100644 --- a/backend/src/Modules/Stories/Knot.Modules.Stories.csproj +++ b/backend/src/Modules/Stories/Knot.Modules.Stories.csproj @@ -11,7 +11,6 @@ - @@ -20,6 +19,7 @@ + diff --git a/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs b/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs index b50f663..502caf0 100644 --- a/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs +++ b/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs @@ -11,10 +11,6 @@ using Host.Application.Stories.Commands.CreateStory; using Host.Application.Stories.Queries.GetUserStories; using Host.Application.Stories.Commands.ViewStory; using Host.Application.Stories.Queries.GetStoryViewers; -using Host.Application.Stories.Commands.AddStoryReaction; -using Host.Application.Stories.Commands.RemoveStoryReaction; -using Host.Application.Stories.Commands.AddStoryReply; -using Host.Application.Stories.Queries.GetStoryReplies; using Host.Application.Stories.Commands.DeleteStory; namespace Knot.Host.Presentation.Endpoints; @@ -56,31 +52,6 @@ public sealed class StoriesEndpoints : ICarterModule return Results.Ok(result.Value); }); - group.MapPost("{id:guid}/reaction", async (Guid id, [FromBody] AddStoryReactionRequest request, ISender sender, IUserContext userContext, CancellationToken ct) => - { - var result = await sender.Send(new AddStoryReactionCommand(userContext.UserId, id, request.Emoji), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(); - }); - - group.MapDelete("{id:guid}/reaction", async (Guid id, [FromBody] RemoveStoryReactionRequest request, ISender sender, IUserContext userContext, CancellationToken ct) => - { - var result = await sender.Send(new RemoveStoryReactionCommand(userContext.UserId, id, request.Emoji), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(); - }); - - group.MapPost("{id:guid}/reply", async (Guid id, [FromBody] AddStoryReplyRequest request, ISender sender, IUserContext userContext, CancellationToken ct) => - { - var result = await sender.Send(new AddStoryReplyCommand(userContext.UserId, id, request.Content), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(); - }); - - group.MapGet("{id:guid}/replies", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) => - { - var result = await sender.Send(new GetStoryRepliesQuery(userContext.UserId, id), ct); - if (result.IsFailure) return result.Error.Code == "Unauthorized" ? Results.Forbid() : Results.NotFound(); - return Results.Ok(result.Value); - }); - group.MapDelete("{id:guid}", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) => { var result = await sender.Send(new DeleteStoryCommand(userContext.UserId, id), ct);