Модуль историй с документацией

This commit is contained in:
Халимов Рустам
2026-03-23 01:43:27 +03:00
parent 6e532b021d
commit 7cb6ac61dd
34 changed files with 392 additions and 918 deletions

View File

@@ -1,10 +0,0 @@
namespace Knot.Modules.Relations.UnitTests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -1,10 +0,0 @@
namespace Knot.Modules.Settings.UnitTests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -1,10 +0,0 @@
namespace Knot.Modules.Storage.UnitTests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -1,10 +0,0 @@
namespace Knot.Modules.Stories.UnitTests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -1,10 +0,0 @@
namespace Knot.Modules.TelegramImport.UnitTests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -1,10 +0,0 @@
namespace Knot.Modules.WebRtc.UnitTests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View 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-запросов.

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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
);

View File

@@ -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
);

View File

@@ -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
);

View File

@@ -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)
{

View File

@@ -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

View File

@@ -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,

View File

@@ -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);

View 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;
}
}

View File

@@ -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++;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,8 @@
namespace Knot.Modules.Stories.Domain;
public enum StoryType
{
Text = 0,
Image = 1,
Video = 2
}

View File

@@ -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);
}
}

View File

@@ -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();
});
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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);