Модуль историй с документацией
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()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
84
backend/src/Docs/stories_module_documentation.md
Normal file
84
backend/src/Docs/stories_module_documentation.md
Normal file
@@ -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<Friendship> Friendships { get; }
|
||||
DbSet<StoryViewer> StoryViewers { get; }
|
||||
DbSet<StoryReaction> StoryReactions { get; }
|
||||
DbSet<StoryReply> StoryReplies { get; }
|
||||
DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
||||
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 @@
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// file removed
|
||||
|
||||
@@ -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<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" => "<22><> Фото",
|
||||
"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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// file removed
|
||||
|
||||
@@ -12,24 +12,28 @@ public record CreateStoryCommand(Guid UserId, string Type, string? MediaUrl, str
|
||||
|
||||
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)
|
||||
{
|
||||
if (!Enum.TryParse<StoryType>(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);
|
||||
}
|
||||
|
||||
@@ -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<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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// file removed
|
||||
|
||||
@@ -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<MessageResp
|
||||
|
||||
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 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;
|
||||
_storyRepository = storyRepository;
|
||||
_userRepository = userRepository;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
@@ -40,9 +44,7 @@ internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand
|
||||
{
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -54,13 +56,14 @@ internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand
|
||||
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 _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<ViewStoryCommand
|
||||
displayName = viewer?.DisplayName,
|
||||
avatar = viewer?.Avatar,
|
||||
viewedAt = DateTime.UtcNow,
|
||||
viewCount = updatedStory.Viewers.Count,
|
||||
viewCount = story.ViewsCount + 1,
|
||||
ownerId = story.UserId
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,11 @@ namespace Knot.Modules.Stories.Application.DTOs;
|
||||
|
||||
public record StoryDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
StoryType Type,
|
||||
string? MediaUrl,
|
||||
string? Content,
|
||||
string? BgColor,
|
||||
DateTime CreatedAt,
|
||||
DateTime ExpiresAt,
|
||||
int ViewCount,
|
||||
bool Viewed,
|
||||
List<StoryReactionDto> Reactions,
|
||||
int ReplyCount
|
||||
bool Viewed
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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<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;
|
||||
|
||||
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<GetStoriesQuery, Li
|
||||
var friendIds = friendships.Select(f => 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<GetStoriesQuery, Li
|
||||
s.Content,
|
||||
s.BgColor,
|
||||
s.CreatedAt,
|
||||
s.ExpiresAt,
|
||||
s.Viewers.Count,
|
||||
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
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
// file removed
|
||||
|
||||
@@ -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<List<Stor
|
||||
|
||||
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;
|
||||
|
||||
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<Result<List<StoryViewerDto>>> 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<GetStoryViewer
|
||||
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>();
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<GetUserStoriesQuery, StoryGroupDto>
|
||||
{
|
||||
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<Result<StoryGroupDto>> 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<GetUserStoriesQ
|
||||
s.Content,
|
||||
s.BgColor,
|
||||
s.CreatedAt,
|
||||
s.ExpiresAt,
|
||||
s.Viewers.Count,
|
||||
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
|
||||
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);
|
||||
|
||||
28
backend/src/Modules/Stories/DependencyInjection.cs
Normal file
28
backend/src/Modules/Stories/DependencyInjection.cs
Normal file
@@ -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 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<StoryViewer> _viewers = new();
|
||||
private readonly List<StoryReaction> _reactions = new();
|
||||
private readonly List<StoryReply> _replies = new();
|
||||
protected Story() : base(Guid.NewGuid()) { }
|
||||
|
||||
public IReadOnlyCollection<StoryViewer> Viewers => _viewers.AsReadOnly();
|
||||
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)
|
||||
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<Guid>
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
8
backend/src/Modules/Stories/Domain/StoryType.cs
Normal file
8
backend/src/Modules/Stories/Domain/StoryType.cs
Normal file
@@ -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;
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="..\Relations\Knot.Modules.Relations.csproj" />
|
||||
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
|
||||
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -20,6 +19,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.7.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user