Модуль историй с документацией
This commit is contained in:
@@ -1,10 +0,0 @@
|
|||||||
namespace Knot.Modules.Relations.UnitTests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Knot.Modules.Settings.UnitTests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Knot.Modules.Storage.UnitTests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Knot.Modules.Stories.UnitTests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Knot.Modules.TelegramImport.UnitTests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Knot.Modules.WebRtc.UnitTests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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-запросов.
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -11,8 +11,6 @@ public interface IStoriesDbContext
|
|||||||
DbSet<Story> Stories { get; }
|
DbSet<Story> Stories { get; }
|
||||||
DbSet<Friendship> Friendships { get; }
|
DbSet<Friendship> Friendships { get; }
|
||||||
DbSet<StoryViewer> StoryViewers { get; }
|
DbSet<StoryViewer> StoryViewers { get; }
|
||||||
DbSet<StoryReaction> StoryReactions { get; }
|
|
||||||
DbSet<StoryReply> StoryReplies { get; }
|
|
||||||
DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Story?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
Task<List<Story>> GetActiveUserStoriesAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||||
|
Task<List<Story>> GetFeedStoriesAsync(List<Guid> userIds, CancellationToken cancellationToken = default);
|
||||||
|
Task IncrementViewsCountAsync(Guid storyId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
+1
-152
@@ -1,152 +1 @@
|
|||||||
using Knot.Modules.Messaging.Domain;
|
// file removed
|
||||||
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<MessageResponse>;
|
|
||||||
|
|
||||||
internal sealed class AddStoryReactionCommandHandler : ICommandHandler<AddStoryReactionCommand, MessageResponse>
|
|
||||||
{
|
|
||||||
private readonly IStoriesDbContext _context;
|
|
||||||
private readonly IUserRepository _userRepository;
|
|
||||||
private readonly IChatRepository _chatRepository;
|
|
||||||
private readonly IMessageRepository _messageRepository;
|
|
||||||
private readonly ISender _sender;
|
|
||||||
private readonly IHubContext<ChatHub> _hubContext;
|
|
||||||
|
|
||||||
public AddStoryReactionCommandHandler(
|
|
||||||
IStoriesDbContext context,
|
|
||||||
IUserRepository userRepository,
|
|
||||||
IChatRepository chatRepository,
|
|
||||||
IMessageRepository messageRepository,
|
|
||||||
ISender sender,
|
|
||||||
IHubContext<ChatHub> hubContext)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
_userRepository = userRepository;
|
|
||||||
_chatRepository = chatRepository;
|
|
||||||
_messageRepository = messageRepository;
|
|
||||||
_sender = sender;
|
|
||||||
_hubContext = hubContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<MessageResponse>> Handle(AddStoryReactionCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
|
|
||||||
if (story == null)
|
|
||||||
{
|
|
||||||
return Result.Failure<MessageResponse>(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<Guid> 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<Guid> { userId1, userId2 });
|
|
||||||
var result = await _sender.Send(command, cancellationToken);
|
|
||||||
return result.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-135
@@ -1,135 +1 @@
|
|||||||
using Knot.Modules.Messaging.Domain;
|
// file removed
|
||||||
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<MessageResponse>;
|
|
||||||
|
|
||||||
internal sealed class AddStoryReplyCommandHandler : ICommandHandler<AddStoryReplyCommand, MessageResponse>
|
|
||||||
{
|
|
||||||
private readonly IStoriesDbContext _context;
|
|
||||||
private readonly IUserRepository _userRepository;
|
|
||||||
private readonly IChatRepository _chatRepository;
|
|
||||||
private readonly IMessageRepository _messageRepository;
|
|
||||||
private readonly ISender _sender;
|
|
||||||
private readonly IHubContext<ChatHub> _hubContext;
|
|
||||||
|
|
||||||
public AddStoryReplyCommandHandler(
|
|
||||||
IStoriesDbContext context,
|
|
||||||
IUserRepository userRepository,
|
|
||||||
IChatRepository chatRepository,
|
|
||||||
IMessageRepository messageRepository,
|
|
||||||
ISender sender,
|
|
||||||
IHubContext<ChatHub> hubContext)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
_userRepository = userRepository;
|
|
||||||
_chatRepository = chatRepository;
|
|
||||||
_messageRepository = messageRepository;
|
|
||||||
_sender = sender;
|
|
||||||
_hubContext = hubContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<MessageResponse>> Handle(AddStoryReplyCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
|
|
||||||
if (story == null)
|
|
||||||
{
|
|
||||||
return Result.Failure<MessageResponse>(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<Guid> 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<Guid> { userId1, userId2 });
|
|
||||||
var result = await _sender.Send(command, cancellationToken);
|
|
||||||
return result.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -12,24 +12,28 @@ public record CreateStoryCommand(Guid UserId, string Type, string? MediaUrl, str
|
|||||||
|
|
||||||
internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCommand, Guid>
|
internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCommand, Guid>
|
||||||
{
|
{
|
||||||
private readonly IStoriesDbContext _context;
|
private readonly IStoryRepository _storyRepository;
|
||||||
|
|
||||||
public CreateStoryCommandHandler(IStoriesDbContext context)
|
public CreateStoryCommandHandler(IStoryRepository storyRepository)
|
||||||
{
|
{
|
||||||
_context = context;
|
_storyRepository = storyRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<Guid>> Handle(CreateStoryCommand request, CancellationToken cancellationToken)
|
public async Task<Result<Guid>> Handle(CreateStoryCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!Enum.TryParse<StoryType>(request.Type, true, out var parsedType))
|
||||||
|
{
|
||||||
|
parsedType = StoryType.Text;
|
||||||
|
}
|
||||||
|
|
||||||
var story = Story.Create(
|
var story = Story.Create(
|
||||||
request.UserId,
|
request.UserId,
|
||||||
request.Type,
|
parsedType,
|
||||||
request.MediaUrl,
|
request.MediaUrl,
|
||||||
request.Content,
|
request.Content,
|
||||||
request.BgColor);
|
request.BgColor);
|
||||||
|
|
||||||
_context.Stories.Add(story);
|
await _storyRepository.AddAsync(story, cancellationToken);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Success(story.Id);
|
return Result.Success(story.Id);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-48
@@ -1,48 +1 @@
|
|||||||
using System;
|
// file removed
|
||||||
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<MessageResponse>;
|
|
||||||
|
|
||||||
internal sealed class RemoveStoryReactionCommandHandler : ICommandHandler<RemoveStoryReactionCommand, MessageResponse>
|
|
||||||
{
|
|
||||||
private readonly IStoriesDbContext _context;
|
|
||||||
|
|
||||||
public RemoveStoryReactionCommandHandler(IStoriesDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<MessageResponse>> 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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+15
-12
@@ -12,10 +12,8 @@ using Microsoft.AspNetCore.SignalR;
|
|||||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
using Knot.Modules.Stories.Application.DTOs;
|
using Knot.Modules.Stories.Application.DTOs;
|
||||||
|
using Knot.Modules.Stories.Infrastructure.Database;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -25,13 +23,19 @@ public record ViewStoryCommand(Guid UserId, Guid StoryId) : ICommand<MessageResp
|
|||||||
|
|
||||||
internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand, MessageResponse>
|
internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand, MessageResponse>
|
||||||
{
|
{
|
||||||
private readonly IStoriesDbContext _context;
|
private readonly StoriesDbContext _context;
|
||||||
|
private readonly Knot.Modules.Stories.Application.Abstractions.IStoryRepository _storyRepository;
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly IHubContext<ChatHub> _hubContext;
|
private readonly IHubContext<ChatHub> _hubContext;
|
||||||
|
|
||||||
public ViewStoryCommandHandler(IStoriesDbContext context, IUserRepository userRepository, IHubContext<ChatHub> hubContext)
|
public ViewStoryCommandHandler(
|
||||||
|
StoriesDbContext context,
|
||||||
|
Knot.Modules.Stories.Application.Abstractions.IStoryRepository storyRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IHubContext<ChatHub> hubContext)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_storyRepository = storyRepository;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_hubContext = hubContext;
|
_hubContext = hubContext;
|
||||||
}
|
}
|
||||||
@@ -40,9 +44,7 @@ internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var story = await _context.Stories
|
var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken);
|
||||||
.Include(s => s.Viewers)
|
|
||||||
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
|
|
||||||
|
|
||||||
if (story == null)
|
if (story == null)
|
||||||
{
|
{
|
||||||
@@ -54,13 +56,14 @@ internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand
|
|||||||
return Result.Success(new MessageResponse("Owner view"));
|
return Result.Success(new MessageResponse("Owner view"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!story.Viewers.Any(v => 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 _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _storyRepository.IncrementViewsCountAsync(story.Id, cancellationToken);
|
||||||
|
|
||||||
var viewer = await _userRepository.GetByIdAsync(request.UserId, 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
|
await _hubContext.Clients.All.SendAsync("story_viewed", new
|
||||||
{
|
{
|
||||||
@@ -70,7 +73,7 @@ internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand
|
|||||||
displayName = viewer?.DisplayName,
|
displayName = viewer?.DisplayName,
|
||||||
avatar = viewer?.Avatar,
|
avatar = viewer?.Avatar,
|
||||||
viewedAt = DateTime.UtcNow,
|
viewedAt = DateTime.UtcNow,
|
||||||
viewCount = updatedStory.Viewers.Count,
|
viewCount = story.ViewsCount + 1,
|
||||||
ownerId = story.UserId
|
ownerId = story.UserId
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,11 @@ namespace Knot.Modules.Stories.Application.DTOs;
|
|||||||
|
|
||||||
public record StoryDto(
|
public record StoryDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Type,
|
StoryType Type,
|
||||||
string? MediaUrl,
|
string? MediaUrl,
|
||||||
string? Content,
|
string? Content,
|
||||||
string? BgColor,
|
string? BgColor,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime ExpiresAt,
|
|
||||||
int ViewCount,
|
int ViewCount,
|
||||||
bool Viewed,
|
bool Viewed
|
||||||
List<StoryReactionDto> Reactions,
|
|
||||||
int ReplyCount
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
+16
-21
@@ -8,13 +8,9 @@ using Knot.Shared.Kernel;
|
|||||||
using Knot.Modules.Stories.Domain;
|
using Knot.Modules.Stories.Domain;
|
||||||
|
|
||||||
|
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Modules.Stories.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Application.Messages.Send;
|
using Knot.Modules.Stories.Infrastructure.Database;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -28,12 +24,14 @@ public record GetStoriesQuery(Guid UserId) : IQuery<List<StoryGroupDto>>;
|
|||||||
|
|
||||||
internal sealed class GetStoriesQueryHandler : IQueryHandler<GetStoriesQuery, List<StoryGroupDto>>
|
internal sealed class GetStoriesQueryHandler : IQueryHandler<GetStoriesQuery, List<StoryGroupDto>>
|
||||||
{
|
{
|
||||||
private readonly IStoriesDbContext _context;
|
private readonly StoriesDbContext _context;
|
||||||
|
private readonly IStoryRepository _storyRepository;
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
|
|
||||||
public GetStoriesQueryHandler(IStoriesDbContext context, IUserRepository userRepository)
|
public GetStoriesQueryHandler(StoriesDbContext context, IStoryRepository storyRepository, IUserRepository userRepository)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_storyRepository = storyRepository;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +44,12 @@ internal sealed class GetStoriesQueryHandler : IQueryHandler<GetStoriesQuery, Li
|
|||||||
var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList();
|
var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList();
|
||||||
friendIds.Add(request.UserId);
|
friendIds.Add(request.UserId);
|
||||||
|
|
||||||
var stories = await _context.Stories
|
var stories = await _storyRepository.GetFeedStoriesAsync(friendIds, cancellationToken);
|
||||||
.Include(s => s.Viewers)
|
var storyIds = stories.Select(s => s.Id).ToList();
|
||||||
.Include(s => s.Reactions)
|
|
||||||
.Include(s => s.Replies)
|
var viewedStoriesIds = await _context.StoryViewers
|
||||||
.Where(s => s.ExpiresAt > DateTime.UtcNow && friendIds.Contains(s.UserId))
|
.Where(v => v.UserId == request.UserId && storyIds.Contains(v.StoryId))
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
.Select(v => v.StoryId)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var groups = stories.GroupBy(s => s.UserId).ToList();
|
var groups = stories.GroupBy(s => s.UserId).ToList();
|
||||||
@@ -84,17 +82,14 @@ internal sealed class GetStoriesQueryHandler : IQueryHandler<GetStoriesQuery, Li
|
|||||||
s.Content,
|
s.Content,
|
||||||
s.BgColor,
|
s.BgColor,
|
||||||
s.CreatedAt,
|
s.CreatedAt,
|
||||||
s.ExpiresAt,
|
s.ViewsCount,
|
||||||
s.Viewers.Count,
|
viewedStoriesIds.Contains(s.Id)
|
||||||
s.Viewers.Any(v => v.UserId == request.UserId),
|
|
||||||
s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(),
|
|
||||||
s.Replies.Count
|
|
||||||
)).OrderBy(s => s.CreatedAt).ToList(),
|
)).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)
|
if (r.User.Id == request.UserId)
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-74
@@ -1,74 +1 @@
|
|||||||
using Host.Application.Stories;
|
// file removed
|
||||||
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<List<StoryReplyDto>>;
|
|
||||||
|
|
||||||
internal sealed class GetStoryRepliesQueryHandler : IQueryHandler<GetStoryRepliesQuery, List<StoryReplyDto>>
|
|
||||||
{
|
|
||||||
private readonly IStoriesDbContext _context;
|
|
||||||
private readonly IUserRepository _userRepository;
|
|
||||||
|
|
||||||
public GetStoryRepliesQueryHandler(IStoriesDbContext context, IUserRepository userRepository)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
_userRepository = userRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<List<StoryReplyDto>>> 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<List<StoryReplyDto>>(StoryErrors.StoryNotFound);
|
|
||||||
}
|
|
||||||
if (story.UserId != request.UserId)
|
|
||||||
{
|
|
||||||
return Result.Failure<List<StoryReplyDto>>(StoryErrors.Unauthorized);
|
|
||||||
}
|
|
||||||
|
|
||||||
var replies = new List<StoryReplyDto>();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
+13
-12
@@ -15,8 +15,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
|
|
||||||
using Knot.Modules.Stories.Application.DTOs;
|
using Knot.Modules.Stories.Application.DTOs;
|
||||||
|
using Knot.Modules.Stories.Infrastructure.Database;
|
||||||
|
|
||||||
|
|
||||||
namespace Host.Application.Stories.Queries.GetStoryViewers;
|
namespace Host.Application.Stories.Queries.GetStoryViewers;
|
||||||
|
|
||||||
@@ -24,20 +23,20 @@ public record GetStoryViewersQuery(Guid UserId, Guid StoryId) : IQuery<List<Stor
|
|||||||
|
|
||||||
internal sealed class GetStoryViewersQueryHandler : IQueryHandler<GetStoryViewersQuery, List<StoryViewerDto>>
|
internal sealed class GetStoryViewersQueryHandler : IQueryHandler<GetStoryViewersQuery, List<StoryViewerDto>>
|
||||||
{
|
{
|
||||||
private readonly IStoriesDbContext _context;
|
private readonly StoriesDbContext _context;
|
||||||
|
private readonly Knot.Modules.Stories.Application.Abstractions.IStoryRepository _storyRepository;
|
||||||
private readonly IUserRepository _userRepository;
|
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;
|
_context = context;
|
||||||
|
_storyRepository = storyRepository;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<List<StoryViewerDto>>> Handle(GetStoryViewersQuery request, CancellationToken cancellationToken)
|
public async Task<Result<List<StoryViewerDto>>> Handle(GetStoryViewersQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var story = await _context.Stories
|
var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken);
|
||||||
.Include(s => s.Viewers)
|
|
||||||
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
|
|
||||||
|
|
||||||
if (story == null)
|
if (story == null)
|
||||||
{
|
{
|
||||||
@@ -48,19 +47,21 @@ internal sealed class GetStoryViewersQueryHandler : IQueryHandler<GetStoryViewer
|
|||||||
return Result.Failure<List<StoryViewerDto>>(StoryErrors.Unauthorized);
|
return Result.Failure<List<StoryViewerDto>>(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<StoryViewerDto>();
|
var viewers = new List<StoryViewerDto>();
|
||||||
|
|
||||||
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)
|
if (user == null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var viewerRecord = story.Viewers.First(v => v.UserId == viewerId);
|
|
||||||
|
|
||||||
viewers.Add(new StoryViewerDto(
|
viewers.Add(new StoryViewerDto(
|
||||||
user.Id,
|
user.Id,
|
||||||
user.Username,
|
user.Username,
|
||||||
|
|||||||
+15
-20
@@ -2,18 +2,14 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Knot.Shared.Kernel;
|
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 Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
using Knot.Modules.Stories.Application.DTOs;
|
using Knot.Modules.Stories.Application.DTOs;
|
||||||
|
|
||||||
|
|
||||||
@@ -24,23 +20,25 @@ public record GetUserStoriesQuery(Guid CurrentUserId, Guid TargetUserId) : IQuer
|
|||||||
|
|
||||||
internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQuery, StoryGroupDto>
|
internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQuery, StoryGroupDto>
|
||||||
{
|
{
|
||||||
private readonly IStoriesDbContext _context;
|
private readonly StoriesDbContext _context;
|
||||||
|
private readonly IStoryRepository _storyRepository;
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
|
|
||||||
public GetUserStoriesQueryHandler(IStoriesDbContext context, IUserRepository userRepository)
|
public GetUserStoriesQueryHandler(StoriesDbContext context, IStoryRepository storyRepository, IUserRepository userRepository)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_storyRepository = storyRepository;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<StoryGroupDto>> Handle(GetUserStoriesQuery request, CancellationToken cancellationToken)
|
public async Task<Result<StoryGroupDto>> Handle(GetUserStoriesQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var stories = await _context.Stories
|
var stories = await _storyRepository.GetActiveUserStoriesAsync(request.TargetUserId, cancellationToken);
|
||||||
.Include(s => s.Viewers)
|
var storyIds = stories.Select(s => s.Id).ToList();
|
||||||
.Include(s => s.Reactions)
|
|
||||||
.Include(s => s.Replies)
|
var viewedStoriesIds = await _context.StoryViewers
|
||||||
.Where(s => s.UserId == request.TargetUserId)
|
.Where(v => v.UserId == request.CurrentUserId && storyIds.Contains(v.StoryId))
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
.Select(v => v.StoryId)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken);
|
var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken);
|
||||||
@@ -56,13 +54,10 @@ internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQ
|
|||||||
s.Content,
|
s.Content,
|
||||||
s.BgColor,
|
s.BgColor,
|
||||||
s.CreatedAt,
|
s.CreatedAt,
|
||||||
s.ExpiresAt,
|
s.ViewsCount,
|
||||||
s.Viewers.Count,
|
viewedStoriesIds.Contains(s.Id)
|
||||||
s.Viewers.Any(v => v.UserId == request.CurrentUserId),
|
|
||||||
s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(),
|
|
||||||
s.Replies.Count
|
|
||||||
)).ToList(),
|
)).ToList(),
|
||||||
stories.Any(s => !s.Viewers.Any(v => v.UserId == request.CurrentUserId))
|
stories.Any(s => !viewedStoriesIds.Contains(s.Id))
|
||||||
);
|
);
|
||||||
|
|
||||||
return Result.Success(result);
|
return Result.Success(result);
|
||||||
|
|||||||
@@ -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<IStoryRepository, StoryRepository>();
|
||||||
|
|
||||||
|
var connectionString = configuration.GetConnectionString("DefaultConnection");
|
||||||
|
services.AddDbContext<Infrastructure.Database.StoriesDbContext>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,24 +5,16 @@ namespace Knot.Modules.Stories.Domain;
|
|||||||
public class Story : Entity<Guid>
|
public class Story : Entity<Guid>
|
||||||
{
|
{
|
||||||
public Guid UserId { get; private set; }
|
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? MediaUrl { get; private set; }
|
||||||
public string? Content { get; private set; }
|
public string? Content { get; private set; }
|
||||||
public string? BgColor { get; private set; }
|
public string? BgColor { get; private set; }
|
||||||
public DateTime CreatedAt { get; private set; }
|
public DateTime CreatedAt { get; private set; }
|
||||||
public DateTime ExpiresAt { get; private set; }
|
public int ViewsCount { get; private set; }
|
||||||
|
|
||||||
private readonly List<StoryViewer> _viewers = new();
|
protected Story() : base(Guid.NewGuid()) { }
|
||||||
private readonly List<StoryReaction> _reactions = new();
|
|
||||||
private readonly List<StoryReply> _replies = new();
|
|
||||||
|
|
||||||
public IReadOnlyCollection<StoryViewer> Viewers => _viewers.AsReadOnly();
|
internal Story(Guid id, Guid userId, StoryType type, string? mediaUrl, string? content, string? bgColor) : base(id)
|
||||||
public IReadOnlyCollection<StoryReaction> Reactions => _reactions.AsReadOnly();
|
|
||||||
public IReadOnlyCollection<StoryReply> 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)
|
|
||||||
{
|
{
|
||||||
UserId = userId;
|
UserId = userId;
|
||||||
Type = type;
|
Type = type;
|
||||||
@@ -30,45 +22,15 @@ public class Story : Entity<Guid>
|
|||||||
Content = content;
|
Content = content;
|
||||||
BgColor = bgColor;
|
BgColor = bgColor;
|
||||||
CreatedAt = DateTime.UtcNow;
|
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);
|
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))
|
ViewsCount++;
|
||||||
{
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
using Knot.Shared.Kernel;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Stories.Domain;
|
|
||||||
|
|
||||||
public sealed class StoryReaction : Entity<Guid>
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using Knot.Shared.Kernel;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Stories.Domain;
|
|
||||||
|
|
||||||
public sealed class StoryReply : Entity<Guid>
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Knot.Modules.Stories.Domain;
|
||||||
|
|
||||||
|
public enum StoryType
|
||||||
|
{
|
||||||
|
Text = 0,
|
||||||
|
Image = 1,
|
||||||
|
Video = 2
|
||||||
|
}
|
||||||
@@ -14,4 +14,9 @@ public sealed class StoryViewer : Entity<Guid>
|
|||||||
UserId = userId;
|
UserId = userId;
|
||||||
ViewedAt = DateTime.UtcNow;
|
ViewedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static StoryViewer Create(Guid storyId, Guid userId)
|
||||||
|
{
|
||||||
|
return new StoryViewer(storyId, userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<StoriesDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DbSet<StoryViewer> StoryViewers => Set<StoryViewer>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasDefaultSchema("stories");
|
||||||
|
|
||||||
|
modelBuilder.Entity<StoryViewer>(builder =>
|
||||||
|
{
|
||||||
|
builder.ToTable("StoryViewers");
|
||||||
|
builder.HasKey(s => s.Id);
|
||||||
|
builder.HasIndex(s => new { s.StoryId, s.UserId }).IsUnique();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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<string>
|
||||||
|
{
|
||||||
|
public static IEncryptionService? EncryptionService { get; set; }
|
||||||
|
|
||||||
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value) || EncryptionService == null)
|
||||||
|
{
|
||||||
|
context.Writer.WriteString(value ?? string.Empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var encrypted = EncryptionService.EncryptMessage(value);
|
||||||
|
context.Writer.WriteString(encrypted);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
context.Writer.WriteString(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
||||||
|
{
|
||||||
|
var value = context.Reader.ReadString();
|
||||||
|
if (string.IsNullOrEmpty(value) || EncryptionService == null)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return EncryptionService.DecryptMessage(value);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fallback for already unencrypted, or failed to decrypt
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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<Story>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Story> _stories;
|
||||||
|
|
||||||
|
public StoryRepository(IMongoDatabase database)
|
||||||
|
{
|
||||||
|
_stories = database.GetCollection<Story>("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<Story?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _stories.Find(s => s.Id == id).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Story>> 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<List<Story>> GetFeedStoriesAsync(List<Guid> 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<Story>.Update.Inc(s => s.ViewsCount, 1);
|
||||||
|
await _stories.UpdateOneAsync(s => s.Id == storyId, update, new UpdateOptions(), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
|
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
|
||||||
<ProjectReference Include="..\Relations\Knot.Modules.Relations.csproj" />
|
<ProjectReference Include="..\Relations\Knot.Modules.Relations.csproj" />
|
||||||
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
|
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
|
||||||
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -20,6 +19,7 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
|
<PackageReference Include="MongoDB.Driver" Version="3.7.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ using Host.Application.Stories.Commands.CreateStory;
|
|||||||
using Host.Application.Stories.Queries.GetUserStories;
|
using Host.Application.Stories.Queries.GetUserStories;
|
||||||
using Host.Application.Stories.Commands.ViewStory;
|
using Host.Application.Stories.Commands.ViewStory;
|
||||||
using Host.Application.Stories.Queries.GetStoryViewers;
|
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;
|
using Host.Application.Stories.Commands.DeleteStory;
|
||||||
|
|
||||||
namespace Knot.Host.Presentation.Endpoints;
|
namespace Knot.Host.Presentation.Endpoints;
|
||||||
@@ -56,31 +52,6 @@ public sealed class StoriesEndpoints : ICarterModule
|
|||||||
return Results.Ok(result.Value);
|
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) =>
|
group.MapDelete("{id:guid}", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new DeleteStoryCommand(userContext.UserId, id), ct);
|
var result = await sender.Send(new DeleteStoryCommand(userContext.UserId, id), ct);
|
||||||
|
|||||||
Reference in New Issue
Block a user