101 Commits
Author SHA1 Message Date
max f4f61071ed внедрил пресеты статусов и модульную архитектуру 2026-04-18 00:43:46 +03:00
max e8c9a55fe9 безопасная обертка IsUserInvisibleAsync 2026-04-17 23:03:15 +03:00
max bccbf12a11 фикс <был недавно> 2026-04-17 22:24:09 +03:00
Халимов Рустам c8b0384392 Референс на контракты профиля 2026-04-17 21:07:09 +03:00
max 5245e8b7ae скрытие статуса online через сокеты и поле isInvisible в профиле 2026-04-17 20:47:28 +03:00
max b346372555 внедрена защищенная смена пароля (бэк + фронт) 2026-04-17 19:52:57 +03:00
max d2e6eb578a внедрил систему статусов (BIO) 2026-04-17 18:33:46 +03:00
max ae710c3d43 поправка групп 2026-04-17 17:56:30 +03:00
Халимов Рустам 600e43eec5 Merge remote-tracking branch 'origin/bugfix_web' into bugfix_web 2026-04-17 00:47:47 +03:00
Халимов Рустам 7b251686b2 Исправление подгрузки данных профиля 2026-04-17 00:43:07 +03:00
max 00682b2977 e# speciallyse enter a commit message to explain why this merge is
necessary,
if it merges an updated upstream into a topic branch.
2026-04-17 00:42:48 +03:00
max e05572fc3f Исправил неработющую функцию добавления пользователя через чат в группу 2026-04-17 00:42:03 +03:00
Халимов Рустам c264b7df27 исправил ошибки компиляции TypeScript 2026-04-17 00:37:37 +03:00
Халимов Рустам 56f75ae32b Получение данных профиля 2026-04-17 00:33:45 +03:00
Халимов Рустам ca9cf27716 О себе, редактирование в профиле 2026-04-17 00:26:38 +03:00
Халимов Рустам 7225e3272e Убрал аватары и имена внутри чата личных чатов 2026-04-16 23:54:19 +03:00
Халимов Рустам 86ae06beb6 Отступы в баблах 2026-04-16 23:48:36 +03:00
Халимов Рустам 454f70f716 Вставка и перетаскивание в поле ввода 2026-04-16 23:42:54 +03:00
Халимов Рустам e700609d30 Дубликат печати, разметка 2026-04-16 23:36:12 +03:00
Халимов Рустам 88f39aa51f Change for env 2026-04-16 22:42:26 +03:00
Халимов Рустам c3dbbaa7b8 Rename all 2026-04-16 22:30:37 +03:00
Халимов Рустам 2eb4f48ca0 Test create 2026-04-16 22:28:41 +03:00
Халимов Рустам 0c1adaab6c Rename web 2026-04-16 22:26:04 +03:00
Халимов Рустам a52726d0e6 Only web 2026-04-16 22:24:41 +03:00
Халимов Рустам d812a7a40c Change services name 2026-04-16 22:21:58 +03:00
Халимов Рустам c8b4fed25a Change ports 2026-04-16 22:18:04 +03:00
Халимов Рустам 8399d32490 Редактирование и плеер 2026-04-08 15:23:10 +03:00
Халимов Рустам 3905094ff4 Правка миграций 2026-04-07 21:22:38 +03:00
Халимов Рустам 9e8625aea1 Миграции 2026-04-07 21:20:06 +03:00
Халимов Рустам 5b905c94da Компоуз 2026-04-07 21:12:34 +03:00
Халимов Рустам 0eecc01374 Сборка 2026-04-07 21:05:23 +03:00
Халимов Рустам c37e1723d4 Фикс .env 2026-04-07 17:29:22 +03:00
Халимов Рустам 02043d4d97 Минимальная адаптация под мобилку 2026-04-07 12:29:41 +03:00
Халимов Рустам 852efa090e Опросы 2026-04-07 11:11:35 +03:00
Халимов Рустам c45f4db61c Заготовка опросов 2026-04-07 01:01:29 +03:00
Халимов Рустам 02a85fc587 Прокрутка 2026-04-06 23:42:46 +03:00
Халимов Рустам e09860700c Удален мусор 2026-04-06 23:36:42 +03:00
Халимов Рустам 32c9bc43cf Починка импорта, плеер для аудио 2026-04-06 23:35:45 +03:00
Халимов Рустам d96e4ec7d4 Правка импорта 2026-04-06 22:35:54 +03:00
Халимов Рустам 1558b20470 Импорт 2026-04-06 22:20:13 +03:00
Халимов Рустам fa185afc73 Информация о звонках в чате 2026-04-06 01:51:48 +03:00
Халимов Рустам 90096ce2bc Звонки 2026-04-06 01:27:27 +03:00
Халимов Рустам 65d5f5fee9 Звонки 2026-04-05 23:41:55 +03:00
Халимов Рустам e11240f78f Аватар 2026-04-05 01:27:58 +03:00
Халимов Рустам 53f193970c Локализация 2026-04-05 00:34:25 +03:00
Халимов Рустам f41ad0bcf8 Мелкие правки 2026-04-05 00:11:10 +03:00
Халимов Рустам b49ff47762 Истории, обрезка 2026-04-03 23:28:00 +03:00
Халимов Рустам 182bbe2ad8 Обрезка видео 2026-04-03 21:32:02 +03:00
Халимов Рустам a04f04a448 Фиксы по историям 2026-04-03 21:13:28 +03:00
Халимов Рустам 2ab5b295e8 Прокрутка дерганая 2026-04-03 16:57:06 +03:00
Халимов Рустам 1b8abbc995 Переделаны истории 2026-04-03 01:03:32 +03:00
Халимов Рустам 943699139b Разделение сообщения 2026-04-02 22:11:03 +03:00
Халимов Рустам 757142eda7 Аватары заглушки 2026-04-02 15:26:05 +03:00
Халимов Рустам a42007df2d Дизайн 2026-04-02 14:57:32 +03:00
Халимов Рустам 9df7d7aaf1 Анимации, профиль, медиа 2026-04-02 02:37:03 +03:00
Халимов Рустам 4ae7dd60ce Рабочий чат 2026-04-01 23:18:55 +03:00
Халимов Рустам 249c344df8 Файлы, правки отображения 2026-04-01 17:42:29 +03:00
Халимов Рустам 2c6d6f831f Правки админки, вход 2026-03-31 23:55:38 +03:00
Халимов Рустам bce8f92101 Восстановление очистки админки 2026-03-31 21:31:22 +03:00
Халимов Рустам aa69c44a64 Новый стиль 2026-03-31 21:11:05 +03:00
Халимов Рустам 4faa7561a0 Админка рабочая 2026-03-31 15:43:24 +03:00
Халимов Рустам 8025340e45 Админка 2026-03-31 01:54:35 +03:00
Халимов Рустам 60d3cf12ae Сборка 2026-03-31 00:38:34 +03:00
Халимов Рустам b09e197626 Сборка 2026-03-31 00:16:18 +03:00
Халимов Рустам f1f8f6e012 Апи 2026-03-30 23:57:34 +03:00
Халимов Рустам d3f1e3f361 Эндпоинты 2026-03-30 23:41:01 +03:00
Халимов Рустам ce212c11c1 Конфиг 2026-03-30 22:54:31 +03:00
Халимов Рустам 143c0b7dc4 Правки 2026-03-30 22:48:35 +03:00
Халимов Рустам cb93ff7240 Зависимости на контракты 2026-03-30 17:48:39 +03:00
Халимов Рустам 09e5cbaa76 Разделение 2026-03-30 15:35:28 +03:00
Халимов Рустам 465e84e686 Очистка 2026-03-30 11:14:20 +03:00
Халимов Рустам 44d59dab2a Удалены тесты 2026-03-30 10:30:06 +03:00
Халимов Рустам b719c50413 Фикс 2026-03-30 01:45:40 +03:00
Халимов Рустам 9438cf1b35 Структура 2026-03-30 01:45:19 +03:00
Халимов Рустам 89b2eeea6c Структура 2026-03-30 00:09:12 +03:00
Халимов Рустам 0209802e9e Контракты 2026-03-29 23:39:17 +03:00
Халимов Рустам 22bc964f27 Билд 2026-03-29 14:26:14 +03:00
Халимов Рустам 9bae9752cc Админка 2026-03-28 01:34:51 +03:00
Халимов Рустам ba5c5210d4 Миграции, сборка 2026-03-27 23:09:16 +03:00
Халимов Рустам 030ae1e4e4 Фронт 2026-03-27 16:04:46 +03:00
Халимов Рустам 28fb8c25de Документация админки 2026-03-27 15:48:09 +03:00
Халимов Рустам 11f2b232a3 Сборка бэк 2026-03-27 15:45:34 +03:00
Халимов Рустам 16978c423c WebRtc модуль и документация, импорт телеграма 2026-03-27 01:44:05 +03:00
Халимов Рустам b399649cfd Модуль связей и Klipy 2026-03-27 01:22:20 +03:00
Халимов Рустам 7ef73b414c Структура, доп модули, федерация, документация 2026-03-27 00:55:01 +03:00
Халимов Рустам 7cb6ac61dd Модуль историй с документацией 2026-03-23 01:43:27 +03:00
Халимов Рустам 6e532b021d Перепиливание под чистый DDD 2026-03-22 23:59:33 +03:00
Халимов Рустам 5da1a2f45d Архитектура 2026-03-21 02:19:38 +03:00
Халимов Рустам f3e0941647 Фикс чата 2026-03-20 01:40:02 +03:00
Халимов Рустам 473e9bcef6 Работа на новой архитектуре 2026-03-20 01:12:00 +03:00
Халимов Рустам a48a0b0977 Fix favorites chat logic and story avatar layout 2026-03-19 23:15:30 +03:00
Халимов Рустам 24f029c9ed Allow anonymous auth for AdminController to let AdminAuthMiddleware handle it natively 2026-03-19 23:11:46 +03:00
Халимов Рустам 1f8746bd03 Fix MongoDB class map hierarchy for inherited Content property 2026-03-19 23:02:28 +03:00
Халимов Рустам 74cae687a5 Fix vite static copy normalizePath for Windows build 2026-03-19 22:50:50 +03:00
Халимов Рустам 71667f8948 Fix web docker context to support nginx config and shared sounds 2026-03-19 22:38:00 +03:00
Халимов Рустам 17d02beb30 Update Dockerfiles and compose paths for new flat structure 2026-03-19 22:34:49 +03:00
Халимов Рустам 77912f2d88 Configure vite-plugin-static-copy for shared assets 2026-03-19 22:28:35 +03:00
Халимов Рустам 275aa0ee7d Update root package.json for client-web workspaces 2026-03-19 22:25:39 +03:00
Халимов Рустам 4384233aa5 Reorganize web folder structurally 2026-03-19 22:24:21 +03:00
Халимов Рустам 11ebbc853b Reorganize root folder structure: Remove apps layer layer 2026-03-19 22:22:45 +03:00
Халимов Рустам fb252f9d87 pre-deep-ddd-refactor 2026-03-19 22:07:23 +03:00
710 changed files with 31872 additions and 15980 deletions
-3
View File
@@ -2,9 +2,6 @@ node_modules
dist dist
.git .git
*.log *.log
apps/web/dist
apps/server/dist
apps/server/uploads
.vscode .vscode
.env .env
bin/ bin/
-30
View File
@@ -1,30 +0,0 @@
# Stage 1: Build React app
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files for apps/web
# We use the local package files to avoid workspace hoisting issues in Docker
COPY apps/web/package.json ./
# If there is a lockfile in apps/web, use it, otherwise use root (but root is workspace, so better not)
# Let's try to generate a clean install
RUN npm install --legacy-peer-deps
# Copy source code
COPY apps/web/ ./
# Build web frontend
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Copy built assets
COPY --from=build /app/dist /usr/share/nginx/html
# Custom Nginx config to proxy /api and /socket.io to server:3001
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-128
View File
@@ -1,128 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{C8E42992-5E42-0C2B-DBFE-AA848D06431C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Knot.Shared.Kernel", "src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj", "{C829F859-FF48-4025-BF22-8E63672D113C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Knot.Shared.Infrastructure", "src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj", "{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{0EC62A1A-9858-3A60-8A0C-FC9AACFA2EC7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Knot.Modules.Identity", "src\Modules\Identity\Knot.Modules.Identity.csproj", "{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Chats", "Chats", "{B4154CE3-9153-E2B0-0515-138FFCE32938}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Knot.Modules.Chats", "src\Modules\Chats\Knot.Modules.Chats.csproj", "{695CC919-E321-4DC0-8D70-C631C51624E1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Knot.Modules.Identity.UnitTests", "tests\Knot.Modules.Identity.UnitTests\Knot.Modules.Identity.UnitTests.csproj", "{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Knot.Modules.Chats.UnitTests", "tests\Knot.Modules.Chats.UnitTests\Knot.Modules.Chats.UnitTests.csproj", "{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C829F859-FF48-4025-BF22-8E63672D113C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C829F859-FF48-4025-BF22-8E63672D113C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C829F859-FF48-4025-BF22-8E63672D113C}.Debug|x64.ActiveCfg = Debug|Any CPU
{C829F859-FF48-4025-BF22-8E63672D113C}.Debug|x64.Build.0 = Debug|Any CPU
{C829F859-FF48-4025-BF22-8E63672D113C}.Debug|x86.ActiveCfg = Debug|Any CPU
{C829F859-FF48-4025-BF22-8E63672D113C}.Debug|x86.Build.0 = Debug|Any CPU
{C829F859-FF48-4025-BF22-8E63672D113C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C829F859-FF48-4025-BF22-8E63672D113C}.Release|Any CPU.Build.0 = Release|Any CPU
{C829F859-FF48-4025-BF22-8E63672D113C}.Release|x64.ActiveCfg = Release|Any CPU
{C829F859-FF48-4025-BF22-8E63672D113C}.Release|x64.Build.0 = Release|Any CPU
{C829F859-FF48-4025-BF22-8E63672D113C}.Release|x86.ActiveCfg = Release|Any CPU
{C829F859-FF48-4025-BF22-8E63672D113C}.Release|x86.Build.0 = Release|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|x64.ActiveCfg = Debug|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|x64.Build.0 = Debug|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|x86.ActiveCfg = Debug|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Debug|x86.Build.0 = Debug|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|Any CPU.Build.0 = Release|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|x64.ActiveCfg = Release|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|x64.Build.0 = Release|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|x86.ActiveCfg = Release|Any CPU
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB}.Release|x86.Build.0 = Release|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|x64.ActiveCfg = Debug|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|x64.Build.0 = Debug|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|x86.ActiveCfg = Debug|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Debug|x86.Build.0 = Debug|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|Any CPU.Build.0 = Release|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|x64.ActiveCfg = Release|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|x64.Build.0 = Release|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|x86.ActiveCfg = Release|Any CPU
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98}.Release|x86.Build.0 = Release|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|x64.ActiveCfg = Debug|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|x64.Build.0 = Debug|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|x86.ActiveCfg = Debug|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Debug|x86.Build.0 = Debug|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Release|Any CPU.Build.0 = Release|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Release|x64.ActiveCfg = Release|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Release|x64.Build.0 = Release|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Release|x86.ActiveCfg = Release|Any CPU
{695CC919-E321-4DC0-8D70-C631C51624E1}.Release|x86.Build.0 = Release|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|x64.ActiveCfg = Debug|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|x64.Build.0 = Debug|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|x86.ActiveCfg = Debug|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Debug|x86.Build.0 = Debug|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|Any CPU.Build.0 = Release|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|x64.ActiveCfg = Release|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|x64.Build.0 = Release|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|x86.ActiveCfg = Release|Any CPU
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD}.Release|x86.Build.0 = Release|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|x64.ActiveCfg = Debug|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|x64.Build.0 = Debug|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|x86.ActiveCfg = Debug|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Debug|x86.Build.0 = Debug|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|Any CPU.Build.0 = Release|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|x64.ActiveCfg = Release|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|x64.Build.0 = Release|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|x86.ActiveCfg = Release|Any CPU
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C8E42992-5E42-0C2B-DBFE-AA848D06431C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C829F859-FF48-4025-BF22-8E63672D113C} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
{6C13E9A4-234C-4BF1-A145-F1BBA2AC56CB} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0EC62A1A-9858-3A60-8A0C-FC9AACFA2EC7} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{5BB2F421-C4C4-40A1-8D6E-FEBB79AE7C98} = {0EC62A1A-9858-3A60-8A0C-FC9AACFA2EC7}
{B4154CE3-9153-E2B0-0515-138FFCE32938} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{695CC919-E321-4DC0-8D70-C631C51624E1} = {B4154CE3-9153-E2B0-0515-138FFCE32938}
{AB2F334E-DD7C-4314-93B9-941AFC6B2ACD} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{F48A3E3A-EEBB-43AA-81AB-7DD7FE5F29B4} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
@@ -1,100 +0,0 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
namespace Host.Application.Admin.Commands;
public record CleanRunCommand(IFileStorageService FileStorage, IdentityDbContext IdentityDb) : ICommand<MessageResponse>;
internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("Messages");
}
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
{
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
foreach (var u in activeMessageUrls)
{
validUrls.Add(u!);
}
foreach (var u in activeChatUrls)
{
validUrls.Add(u);
}
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
{
await request.FileStorage.DeleteFileAsync(file.FileId);
}
}
if (orphanMessages.Any())
{
var orphanIds = orphanMessages.Select(m => m.Id).ToList();
var filter = Builders<Message>.Filter.In(m => m.Id, orphanIds);
await _messages.DeleteManyAsync(filter, cancellationToken);
}
return Result.Success(new MessageResponse("Cleanup completed successfully"));
}
}
@@ -1,26 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
namespace Host.Application.Admin.Commands;
public record UpdateSettingsCommand(SystemSettingsDto Settings) : ICommand<SystemSettingsDto>;
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, SystemSettingsDto>
{
private readonly ISettingsService _settingsService;
public UpdateSettingsCommandHandler(ISettingsService settingsService)
{
_settingsService = settingsService;
}
public async Task<Result<SystemSettingsDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
{
await _settingsService.UpdateSettingsAsync(request.Settings, cancellationToken);
return Result.Success(request.Settings);
}
}
@@ -1,101 +0,0 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Admin.Queries;
public record CleanDryRunQuery(IFileStorageService FileStorage, IdentityDbContext IdentityDb) : IQuery<CleanupDryRunResultDto>;
internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("Messages");
}
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
{
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanedMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var orphanedMessagesCount = orphanedMessages.Count;
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
foreach (var u in activeMessageUrls)
{
validUrls.Add(u!);
}
foreach (var u in activeChatUrls)
{
validUrls.Add(u);
}
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
long safeBytes = 0;
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
{
safeBytes += file.Size;
}
}
return Result.Success(new CleanupDryRunResultDto
{
OrphanedMessagesCount = orphanedMessagesCount,
OrphanedMediaBytes = safeBytes
});
}
}
@@ -1,72 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
namespace Host.Application.Admin.Queries;
public record GetUserDetailsQuery(Guid UserId) : IQuery<AdminUserDetailsDto>;
internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDetailsDto>
{
private readonly IUserRepository _userRepository;
private readonly IMongoCollection<Message> _messages;
public GetUserDetailsQueryHandler(IUserRepository userRepository, IMongoDatabase mongoDatabase)
{
_userRepository = userRepository;
_messages = mongoDatabase.GetCollection<Message>("Messages");
}
public async Task<Result<AdminUserDetailsDto>> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken)
{
var targetUser = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (targetUser == null)
{
return Result.Failure<AdminUserDetailsDto>(IdentityErrors.UserNotFound);
}
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId);
var userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
var messagesCount = userMessages.Count;
var allUserMedia = userMessages.OfType<MediaMessage>().SelectMany(m => m.Media).ToList();
var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video");
var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio");
var storageUsed = allUserMedia.Sum(m => m.Size ?? 0);
var userContents = userMessages.OfType<TextMessage>().Select(m => m.Content)
.Concat(userMessages.OfType<MediaMessage>().Where(m => m.Caption != null).Select(m => m.Caption))
.ToList();
var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http"));
var result = new AdminUserDetailsDto(
targetUser.Id,
targetUser.Username,
targetUser.DisplayName,
targetUser.Bio,
targetUser.Avatar,
targetUser.CreatedAt,
Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()),
Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt,
new AdminUserStatsDto(
messagesCount,
mediaCount,
filesCount,
linksCount,
storageUsed
)
);
return Result.Success(result);
}
}
@@ -1,43 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Modules.Identity.Domain;
namespace Host.Application.Admin.Queries;
public record SearchUsersQuery(string Query) : IQuery<List<AdminUserDto>>;
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<AdminUserDto>>
{
private readonly IUserRepository _userRepository;
public SearchUsersQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<List<AdminUserDto>>> Handle(SearchUsersQuery request, CancellationToken cancellationToken)
{
var users = string.IsNullOrWhiteSpace(request.Query)
? await _userRepository.SearchUsersAsync("", cancellationToken)
: await _userRepository.SearchUsersAsync(request.Query, cancellationToken);
var result = users.Select(u => new AdminUserDto(
u.Id,
u.Username,
u.DisplayName,
u.Email,
u.Avatar,
u.CreatedAt,
Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()),
Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt
)).ToList();
return Result.Success(result);
}
}
@@ -1,25 +0,0 @@
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using MediatR;
using Host.Models;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Application.Config.Queries;
public record GetPublicConfigQuery : IQuery<PublicConfigDto>;
internal sealed class GetPublicConfigQueryHandler : IQueryHandler<GetPublicConfigQuery, PublicConfigDto>
{
private readonly ISettingsService _settings;
public GetPublicConfigQueryHandler(ISettingsService settings)
{
_settings = settings;
}
public Task<Result<PublicConfigDto>> Handle(GetPublicConfigQuery request, CancellationToken cancellationToken)
{
return Task.FromResult(Result.Success(PublicConfigDto.FromSettings(_settings.Current)));
}
}
@@ -1,52 +0,0 @@
using Host.Application.Stories;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Constants;
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
namespace Host.Application.Federation.Commands;
public record HandshakeRequest(string Domain, string PublicKey);
public record HandshakeResponse(string Domain, string PublicKey, string Status);
public record HandshakeFederationCommand(HandshakeRequest Request) : ICommand<HandshakeResponse>;
internal sealed class HandshakeFederationCommandHandler : ICommandHandler<HandshakeFederationCommand, HandshakeResponse>
{
private readonly ISettingsService _settings;
public HandshakeFederationCommandHandler(ISettingsService settings)
{
_settings = settings;
}
public Task<Result<HandshakeResponse>> Handle(HandshakeFederationCommand request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.EnableConfederation)
return Task.FromResult(Result.Failure<HandshakeResponse>(new Error(Errors.DisabledByAdmin, "Federation is disabled")));
if (string.IsNullOrEmpty(request.Request.Domain) || string.IsNullOrEmpty(request.Request.PublicKey))
return Task.FromResult(Result.Failure<HandshakeResponse>(DomainErrors.RequestInvalid));
var allowedList = conf.AllowedDomains?.Select(d => d.Trim().ToLower()).ToList() ?? new System.Collections.Generic.List<string>();
if (!allowedList.Contains(request.Request.Domain.ToLowerInvariant()))
return Task.FromResult(Result.Failure<HandshakeResponse>(StoryErrors.Unauthorized));
using var rsa = RSA.Create(2048);
var selfPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
var response = new HandshakeResponse(
Environment.GetEnvironmentVariable("DOMAIN") ?? "knot.local",
selfPublicKey,
"Accepted"
);
return Task.FromResult(Result.Success(response));
}
}
@@ -1,141 +0,0 @@
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.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Messages.Send;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Host.Models;
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 IdentityDbContext _context;
private readonly IUserRepository _userRepository;
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly ISender _sender;
private readonly IHubContext<ChatHub> _hubContext;
public AddStoryReactionCommandHandler(
IdentityDbContext 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.Chats.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.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { userId1, userId2 });
var result = await _sender.Send(command, cancellationToken);
return result.Value;
}
}
@@ -1,124 +0,0 @@
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.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Messages.Send;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Host.Models;
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 IdentityDbContext _context;
private readonly IUserRepository _userRepository;
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly ISender _sender;
private readonly IHubContext<ChatHub> _hubContext;
public AddStoryReplyCommandHandler(
IdentityDbContext context,
IUserRepository userRepository,
IChatRepository chatRepository,
IMessageRepository messageRepository,
ISender sender,
IHubContext<ChatHub> hubContext)
{
_context = context;
_userRepository = userRepository;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_sender = sender;
_hubContext = hubContext;
}
public async Task<Result<MessageResponse>> Handle(AddStoryReplyCommand request, CancellationToken cancellationToken)
{
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
if (story == null)
{
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
}
var reply = new StoryReply(request.StoryId, request.UserId, request.Content);
_context.StoryReplies.Add(reply);
await _context.SaveChangesAsync(cancellationToken);
var chatId = await GetOrCreatePersonalChatIdAsync(request.UserId, story.UserId, cancellationToken);
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, cancellationToken);
var storyQuote = GetStoryQuote(story);
var messageCommand = new SendMessageCommand(
ChatId: chatId,
SenderId: request.UserId,
Content: request.Content,
Type: "text",
Quote: storyQuote,
ReplyToId: lastStoryMessage?.Id,
StoryId: story.Id,
StoryMediaUrl: story.MediaUrl,
StoryMediaType: story.Type,
Attachments: null,
ForwardedFromId: null);
await _sender.Send(messageCommand, cancellationToken);
var replier = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
await _hubContext.Clients.All.SendAsync("story_reply", new
{
storyId = story.Id,
userId = request.UserId,
username = replier?.Username,
displayName = replier?.DisplayName,
avatar = replier?.Avatar,
content = request.Content,
createdAt = DateTime.UtcNow,
ownerId = story.UserId
}, cancellationToken);
return Result.Success(new MessageResponse("Reply added"));
}
private string GetStoryQuote(Story story)
{
if (!string.IsNullOrEmpty(story.Content))
{
return story.Content;
}
return story.Type.ToLower() switch
{
"image" => "�� Фото",
"video" => "🎬 Видео",
_ => "История"
};
}
private async Task<Guid> GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken cancellationToken)
{
var userChats = await _chatRepository.GetUserChatsAsync(userId1, cancellationToken);
var personalChat = userChats.FirstOrDefault(c =>
c.Type == ChatType.Personal &&
c.Members.Any(m => m.UserId == userId2));
if (personalChat != null)
{
return personalChat.Id;
}
var command = new Knot.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { userId1, userId2 });
var result = await _sender.Send(command, cancellationToken);
return result.Value;
}
}
@@ -1,36 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Domain;
namespace Host.Application.Stories.Commands.CreateStory;
public record CreateStoryCommand(Guid UserId, string Type, string? MediaUrl, string? Content, string? BgColor) : ICommand<Guid>;
internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCommand, Guid>
{
private readonly IdentityDbContext _context;
public CreateStoryCommandHandler(IdentityDbContext context)
{
_context = context;
}
public async Task<Result<Guid>> Handle(CreateStoryCommand request, CancellationToken cancellationToken)
{
var story = Story.Create(
request.UserId,
request.Type,
request.MediaUrl,
request.Content,
request.BgColor);
_context.Stories.Add(story);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success(story.Id);
}
}
@@ -1,39 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Host.Models;
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 IdentityDbContext _context;
public RemoveStoryReactionCommandHandler(IdentityDbContext 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"));
}
}
@@ -1,77 +0,0 @@
using Host.Application.Stories;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Host.Models;
namespace Host.Application.Stories.Commands.ViewStory;
public record ViewStoryCommand(Guid UserId, Guid StoryId) : ICommand<MessageResponse>;
internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand, MessageResponse>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
private readonly IHubContext<ChatHub> _hubContext;
public ViewStoryCommandHandler(IdentityDbContext context, IUserRepository userRepository, IHubContext<ChatHub> hubContext)
{
_context = context;
_userRepository = userRepository;
_hubContext = hubContext;
}
public async Task<Result<MessageResponse>> Handle(ViewStoryCommand request, CancellationToken cancellationToken)
{
try
{
var story = await _context.Stories
.Include(s => s.Viewers)
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
if (story == null)
{
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
}
if (story.UserId == request.UserId)
{
return Result.Success(new MessageResponse("Owner view"));
}
if (!story.Viewers.Any(v => v.UserId == request.UserId))
{
story.AddViewer(request.UserId);
await _context.SaveChangesAsync(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
{
storyId = story.Id,
userId = request.UserId,
username = viewer?.Username,
displayName = viewer?.DisplayName,
avatar = viewer?.Avatar,
viewedAt = DateTime.UtcNow,
viewCount = updatedStory.Viewers.Count,
ownerId = story.UserId
}, cancellationToken);
}
return Result.Success(new MessageResponse("Story viewed"));
}
catch (DbUpdateException)
{
return Result.Success(new MessageResponse("Story already viewed"));
}
}
}
@@ -1,66 +0,0 @@
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.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Host.Models;
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 IdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetStoryRepliesQueryHandler(IdentityDbContext 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);
}
}
@@ -1,65 +0,0 @@
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.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Stories.Queries.GetUserStories;
public record GetUserStoriesQuery(Guid CurrentUserId, Guid TargetUserId) : IQuery<StoryGroupDto>;
internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQuery, StoryGroupDto>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetUserStoriesQueryHandler(IdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_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)
.ToListAsync(cancellationToken);
var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken);
if (user == null)
{
return Result.Failure<StoryGroupDto>(IdentityErrors.UserNotFound);
}
var result = new StoryGroupDto(
new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
stories.Select(s => new StoryDto(
s.Id,
s.Type,
s.MediaUrl,
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
)).ToList(),
stories.Any(s => !s.Viewers.Any(v => v.UserId == request.CurrentUserId))
);
return Result.Success(result);
}
}
@@ -1,73 +0,0 @@
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Microsoft.Extensions.Configuration;
using MediatR;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Application.WebRtc.Queries;
public record IceServerDto(string[] Urls, string? Username = null, string? Credential = null);
public record IceServersResultDto(List<IceServerDto> IceServers);
public record GetIceServersQuery : IQuery<IceServersResultDto>;
internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQuery, IceServersResultDto>
{
private readonly IConfiguration _configuration;
private readonly ISettingsService _settingsService;
public GetIceServersQueryHandler(IConfiguration configuration, ISettingsService settingsService)
{
_configuration = configuration;
_settingsService = settingsService;
}
public Task<Result<IceServersResultDto>> Handle(GetIceServersQuery request, CancellationToken cancellationToken)
{
var settings = _settingsService.Current;
if (!settings.EnableCalls)
{
return Task.FromResult(Result.Failure<IceServersResultDto>(new Error(
Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin,
"Сервис отключен администратором."
)));
}
var turnUrl = !string.IsNullOrEmpty(settings.TurnHost)
? $"turn:{settings.TurnHost}:{settings.TurnPort}"
: _configuration["WebRtc:TurnUrl"];
var turnUsername = !string.IsNullOrEmpty(settings.TurnUser)
? settings.TurnUser
: _configuration["WebRtc:TurnUsername"];
var turnSecret = !string.IsNullOrEmpty(settings.TurnSecret)
? settings.TurnSecret
: _configuration["WebRtc:TurnPassword"];
var iceServers = new List<IceServerDto>();
if (!string.IsNullOrEmpty(turnUrl))
{
var stunUrl = turnUrl.Replace("turn:", "stun:");
iceServers.Add(new IceServerDto(new[] { stunUrl }));
if (!string.IsNullOrEmpty(turnUsername))
{
iceServers.Add(new IceServerDto(
new[] { turnUrl, turnUrl + "?transport=tcp" },
turnUsername,
!string.IsNullOrEmpty(turnSecret) ? turnSecret : turnUsername
));
}
else
{
iceServers.Add(new IceServerDto(new[] { turnUrl, turnUrl + "?transport=tcp" }));
}
}
return Task.FromResult(Result.Success(new IceServersResultDto(iceServers)));
}
}
@@ -1,110 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Host.Models;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Identity.Application.Users.Register;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Identity.Infrastructure.Persistence;
using System.Threading;
using System.Threading.Tasks;
using System;
using MediatR;
using Host.Application.Admin.Queries;
using Host.Application.Admin.Commands;
using Microsoft.AspNetCore.Authorization;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
private readonly ISender _sender;
public AdminController(ISender sender)
{
_sender = sender;
}
[HttpGet("settings")]
public async Task<IActionResult> GetSettings(CancellationToken ct)
{
var result = await _sender.Send(new GetSettingsQuery(), ct);
return Ok(result.Value);
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] SystemSettingsDto settings, CancellationToken ct)
{
var result = await _sender.Send(new UpdateSettingsCommand(settings), ct);
return Ok(result.Value);
}
[HttpGet("dashboard")]
public async Task<IActionResult> GetDashboardStats(CancellationToken ct)
{
var result = await _sender.Send(new GetDashboardStatsQuery(), ct);
return Ok(result.Value);
}
[HttpPost("users")]
public async Task<IActionResult> CreateUser([FromBody] RegisterUserCommand command, CancellationToken ct)
{
var result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(new { error = result.Error.Description });
var userDetails = await _sender.Send(new GetUserDetailsQuery(result.Value.User.Id), ct);
return Ok(userDetails.Value);
}
[HttpPost("users/{userId:guid}/reset-password")]
public async Task<IActionResult> ResetUserPassword(Guid userId, [FromBody] ResetPasswordDto dto, CancellationToken ct)
{
var result = await _sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
if (result.IsFailure)
{
if (result.Error.Code == "User.NotFound") return NotFound(new { error = "User not found" });
return BadRequest(new { error = result.Error.Description });
}
return Ok(result.Value);
}
[HttpGet("users")]
public async Task<IActionResult> SearchUsers([FromQuery] string query = "", CancellationToken ct = default)
{
var result = await _sender.Send(new SearchUsersQuery(query), ct);
return Ok(result.Value);
}
[HttpGet("users/{id:guid}")]
public async Task<IActionResult> GetUserDetails(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetUserDetailsQuery(id), ct);
if (result.IsFailure)
{
return NotFound("User not found");
}
return Ok(result.Value);
}
[HttpGet("clean/dry-run")]
public async Task<IActionResult> CleanDryRun(
[FromServices] IFileStorageService fileStorage,
[FromServices] IdentityDbContext identityDb,
CancellationToken ct)
{
var result = await _sender.Send(new CleanDryRunQuery(fileStorage, identityDb), ct);
return Ok(result.Value);
}
[HttpPost("clean/run")]
public async Task<IActionResult> CleanRun(
[FromServices] IFileStorageService fileStorage,
[FromServices] IdentityDbContext identityDb,
CancellationToken ct)
{
var result = await _sender.Send(new CleanRunCommand(fileStorage, identityDb), ct);
return Ok(result.Value);
}
}
@@ -1,50 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Users.Auth;
using Knot.Modules.Identity.Application.Users.Register;
using Knot.Modules.Identity.Application.Users.Login;
using Knot.Modules.Identity.Application.Users.GetMe;
using MediatR;
namespace Host.Controllers;
[ApiController]
[Route("api/[controller]")]
public sealed class AuthController : ControllerBase
{
private readonly ISender _sender;
public AuthController(ISender sender)
{
_sender = sender;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterUserCommand command, CancellationToken ct)
{
var result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(new { error = result.Error.Code ?? result.Error.Description });
return Ok(result.Value);
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> GetMe([FromServices] IUserContext userContext, CancellationToken ct)
{
var result = await _sender.Send(new GetMeQuery(userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(new { User = result.Value.User });
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginUserCommand command, CancellationToken ct)
{
var result = await _sender.Send(command, ct);
if (result.IsFailure) return Unauthorized(new { error = result.Error.Code ?? result.Error.Description });
return Ok(result.Value);
}
}
@@ -1,241 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Chats.Application.Chats.GetChats;
using Knot.Modules.Chats.Application.Chats.GetChatById;
using Knot.Modules.Chats.Application.Chats.Create;
using Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites;
using Knot.Modules.Chats.Application.Chats.Update;
using Knot.Modules.Chats.Application.Chats.LeaveOrDelete;
using Knot.Modules.Chats.Application.Chats.Clear;
using Knot.Modules.Chats.Application.Chats.TogglePin;
using Knot.Modules.Chats.Application.Chats.Members;
using Knot.Modules.Chats.Application.Chats.Avatar;
using Knot.Modules.Chats.Domain;
using MediatR;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/chats")]
public sealed class ChatsController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public ChatsController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet]
public async Task<IActionResult> GetChats(CancellationToken ct)
{
var result = await _sender.Send(new GetChatsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateChatRequest request, CancellationToken ct)
{
var command = new CreateChatCommand(request.Name, request.Type, request.MemberIds);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("personal")]
public async Task<IActionResult> CreatePersonal([FromBody] CreatePersonalChatRequest request, CancellationToken ct)
{
var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { _userContext.UserId, request.UserId });
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("group")]
public async Task<IActionResult> CreateGroup([FromBody] CreateGroupChatRequest request, CancellationToken ct)
{
var memberIds = request.MemberIds.ToList();
if (memberIds.Contains(_userContext.UserId))
{
memberIds.Remove(_userContext.UserId);
}
memberIds.Insert(0, _userContext.UserId);
var command = new CreateChatCommand(request.Name, ChatType.Group, memberIds);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("favorites")]
public async Task<IActionResult> GetOrCreateFavorites(CancellationToken ct)
{
var result = await _sender.Send(new GetOrCreateFavoritesCommand(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateChat(Guid id, [FromBody] UpdateChatRequest request, CancellationToken ct)
{
var result = await _sender.Send(new UpdateChatCommand(id, _userContext.UserId, request.Name, request.Description), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> LeaveOrDeleteChat(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new LeaveOrDeleteChatCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id:guid}/clear")]
public async Task<IActionResult> ClearChat(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new ClearChatCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id:guid}/pin")]
public async Task<IActionResult> TogglePin(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new TogglePinCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id:guid}/members")]
public async Task<IActionResult> AddMembers(Guid id, [FromBody] AddMembersRequest request, CancellationToken ct)
{
var result = await _sender.Send(new AddMembersCommand(id, _userContext.UserId, request.UserIds.ToList()), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}/members/{userId:guid}")]
public async Task<IActionResult> RemoveMember(Guid id, Guid userId, CancellationToken ct)
{
var result = await _sender.Send(new RemoveMemberCommand(id, _userContext.UserId, userId), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("{id:guid}/avatar")]
public async Task<IActionResult> UploadGroupAvatar(Guid id, Microsoft.AspNetCore.Http.IFormFile avatar, CancellationToken ct)
{
if (avatar == null || avatar.Length == 0)
{
return BadRequest("No file");
}
using var stream = avatar.OpenReadStream();
var result = await _sender.Send(new UploadGroupAvatarCommand(id, _userContext.UserId, avatar.FileName, avatar.ContentType, stream), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("{id:guid}/avatar/crop")]
public async Task<IActionResult> CropGroupAvatar(Guid id, [FromForm] Microsoft.AspNetCore.Http.IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct)
{
if (avatar == null || avatar.Length == 0)
{
return BadRequest("No file");
}
using var stream = avatar.OpenReadStream();
var result = await _sender.Send(new CropGroupAvatarCommand(id, _userContext.UserId, avatar.FileName, avatar.ContentType, stream, x, y, width, height), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}/avatar")]
public async Task<IActionResult> RemoveGroupAvatar(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new RemoveGroupAvatarCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
}
@@ -1,34 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel.Storage;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/files")]
public sealed class FilesController : ControllerBase
{
private readonly IFileStorageService _fileStorage;
public FilesController(IFileStorageService fileStorage)
{
_fileStorage = fileStorage;
}
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<IActionResult> DownloadFile(string id)
{
try
{
var result = await _fileStorage.DownloadFileAsync(id);
// Обратите внимание, что мы возвращаем поток с автоматическим освобождением памяти.
return File(result.Stream, result.ContentType, result.FileName);
}
catch (Exception ex)
{
return NotFound(new { error = "Файл не найден или ошибка доступа.", message = ex.Message });
}
}
}
@@ -1,113 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Friends;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/friends")]
public sealed class FriendsController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public FriendsController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet]
public async Task<IActionResult> GetFriends(CancellationToken ct)
{
var result = await _sender.Send(new GetFriendsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpGet("requests")]
public async Task<IActionResult> GetRequests(CancellationToken ct)
{
var result = await _sender.Send(new GetIncomingRequestsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpGet("outgoing")]
public async Task<IActionResult> GetOutgoingRequests(CancellationToken ct)
{
var result = await _sender.Send(new GetOutgoingRequestsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpPost("request")]
public async Task<IActionResult> SendRequest([FromBody] Host.Models.SendFriendRequest request, CancellationToken ct)
{
var result = await _sender.Send(new SendFriendRequestCommand(_userContext.UserId, request.FriendId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(new { status = "pending" });
}
[HttpPost("{id:guid}/accept")]
public async Task<IActionResult> AcceptRequest(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new AcceptFriendRequestCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound(result.Error.Description);
}
return Ok(new { id = result.Value });
}
[HttpPost("{id:guid}/decline")]
public async Task<IActionResult> DeclineRequest(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new DeclineFriendRequestCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound(result.Error.Description);
}
return Ok(new Knot.Shared.Kernel.SuccessResponse(true));
}
[HttpGet("status/{userId:guid}")]
public async Task<IActionResult> GetStatus(Guid userId, CancellationToken ct)
{
var result = await _sender.Send(new GetFriendshipStatusQuery(_userContext.UserId, userId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> RemoveFriend(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new RemoveFriendCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound(result.Error.Description);
}
return Ok(new Knot.Shared.Kernel.SuccessResponse(true));
}
}
@@ -1,114 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Shared.Kernel;
using System.Threading;
using System.Threading.Tasks;
using System;
using Microsoft.AspNetCore.Http;
using MediatR;
using Knot.Modules.Chats.Application.Messages.GetMessages;
using Knot.Modules.Chats.Application.Messages.SearchMessages;
using Knot.Modules.Chats.Application.Messages.UploadFile;
using Knot.Modules.Chats.Application.Messages.GetSharedMedia;
using Knot.Modules.Chats.Application.Messages.Send;
using System.Linq;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/messages")]
public sealed class MessagesController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public MessagesController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet("chat/{chatId:guid}")]
public async Task<IActionResult> GetMessages(Guid chatId, [FromQuery] string? cursor, CancellationToken ct = default)
{
var result = await _sender.Send(new GetMessagesQuery(_userContext.UserId, chatId, cursor), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpGet("search")]
public async Task<IActionResult> GetSearch([FromQuery] string q, [FromQuery] Guid? chatId, CancellationToken ct)
{
var result = await _sender.Send(new SearchMessagesQuery(_userContext.UserId, q, chatId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpPost("upload")]
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct)
{
if (file == null || file.Length == 0)
{
return BadRequest("No file uploaded");
}
using var stream = file.OpenReadStream();
var result = await _sender.Send(new UploadFileCommand(file.FileName, file.ContentType, file.Length, stream), ct);
if (result.IsFailure)
{
if (result.Error.Code == "File.TooLarge")
{
return StatusCode(413, result.Error.Description);
}
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpGet("chat/{chatId:guid}/shared")]
public async Task<IActionResult> GetSharedMedia(Guid chatId, [FromQuery] string? type, CancellationToken ct)
{
var result = await _sender.Send(new GetSharedMediaQuery(_userContext.UserId, chatId, type), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpPost("chat/{chatId:guid}")]
public async Task<IActionResult> SendMessage(Guid chatId, [FromBody] SendMessageRequest request, CancellationToken ct)
{
var attachments = request.Attachments?.Select(a =>
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
var command = new SendMessageCommand(
chatId,
_userContext.UserId,
request.Content,
request.Type,
attachments,
request.ReplyToId,
request.Quote,
request.ForwardedFromId);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
}
@@ -1,149 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Host.Models;
using Knot.Shared.Kernel;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Host.Application.Stories.Queries.GetStories;
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 Host.Controllers;
[Authorize]
[ApiController]
[Route("api/stories")]
public sealed class StoriesController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public StoriesController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet]
public async Task<IActionResult> GetStories(CancellationToken ct)
{
var result = await _sender.Send(new GetStoriesQuery(_userContext.UserId), ct);
return Ok(result.Value);
}
[HttpPost]
public async Task<IActionResult> CreateStory([FromBody] CreateStoryRequest request, CancellationToken ct)
{
var result = await _sender.Send(new CreateStoryCommand(_userContext.UserId, request.Type, request.MediaUrl, request.Content, request.BgColor), ct);
return Ok(new { id = result.Value });
}
[HttpGet("user/{userId}")]
public async Task<IActionResult> GetUserStories(Guid userId, CancellationToken ct)
{
var result = await _sender.Send(new GetUserStoriesQuery(_userContext.UserId, userId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id}/view")]
public async Task<IActionResult> ViewStory(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new ViewStoryCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpGet("{id}/viewers")]
public async Task<IActionResult> GetStoryViewers(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetStoryViewersQuery(_userContext.UserId, id), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id}/reaction")]
public async Task<IActionResult> AddReaction(Guid id, [FromBody] AddStoryReactionRequest request, CancellationToken ct)
{
var result = await _sender.Send(new AddStoryReactionCommand(_userContext.UserId, id, request.Emoji), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpDelete("{id}/reaction")]
public async Task<IActionResult> RemoveReaction(Guid id, [FromBody] RemoveStoryReactionRequest request, CancellationToken ct)
{
var result = await _sender.Send(new RemoveStoryReactionCommand(_userContext.UserId, id, request.Emoji), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id}/reply")]
public async Task<IActionResult> AddReply(Guid id, [FromBody] AddStoryReplyRequest request, CancellationToken ct)
{
var result = await _sender.Send(new AddStoryReplyCommand(_userContext.UserId, id, request.Content), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpGet("{id}/replies")]
public async Task<IActionResult> GetReplies(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetStoryRepliesQuery(_userContext.UserId, id), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteStory(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new DeleteStoryCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
}
@@ -1,64 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using MediatR;
using Host.Models;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.TelegramImport;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/import/telegram")]
public sealed class TelegramImportController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public TelegramImportController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpPost("analyze")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10GB for big exports
public async Task<IActionResult> Analyze(IFormFile file, CancellationToken ct)
{
if (file == null)
{
return BadRequest("No file uploaded.");
}
using var stream = file.OpenReadStream();
var command = new AnalyzeImportCommand(stream, file.FileName);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description ?? result.Error.Code);
}
return Ok(result.Value);
}
[HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] ExecuteImportRequest req, CancellationToken ct)
{
var command = new ExecuteImportCommand(_userContext.UserId, req.Token, req.Mapping, req.GroupName);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description ?? result.Error.Code);
}
return Ok(result.Value);
}
}
@@ -1,134 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Shared.Kernel.Storage;
using MediatR;
using Knot.Modules.Identity.Application.Users;
using Knot.Modules.Identity.Application.Users.Avatar;
using Knot.Modules.Identity.Application.Users.GetUser;
using Knot.Modules.Identity.Application.Users.Search;
using Knot.Modules.Identity.Application.Users.UpdateProfile;
using Knot.Modules.Identity.Application.Users.UpdateSettings;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/users")]
public sealed class UsersController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public UsersController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] string q, CancellationToken ct)
{
var result = await _sender.Send(new SearchUsersQuery(q), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] UpdateSettingsRequest request, CancellationToken ct)
{
var result = await _sender.Send(new UpdateSettingsCommand(_userContext.UserId, request.HideStoryViews), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpPost("avatar")]
public async Task<IActionResult> UploadAvatar(IFormFile avatar, CancellationToken ct)
{
var fileToUpload = avatar ?? Request.Form.Files.FirstOrDefault();
if (fileToUpload == null || fileToUpload.Length == 0)
{
return BadRequest("No file uploaded");
}
using var stream = fileToUpload.OpenReadStream();
var command = new UploadAvatarCommand(
_userContext.UserId,
stream,
fileToUpload.FileName,
fileToUpload.ContentType
);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpPost("avatar/crop")]
public async Task<IActionResult> CropAvatar([FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct)
{
if (avatar == null || avatar.Length == 0)
{
return BadRequest("No file uploaded");
}
using var stream = avatar.OpenReadStream();
var command = new CropAvatarCommand(
_userContext.UserId,
stream,
avatar.FileName ?? "avatar.jpg",
avatar.ContentType,
x, y, width, height
);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpDelete("avatar")]
public async Task<IActionResult> DeleteAvatar(CancellationToken ct)
{
var result = await _sender.Send(new DeleteAvatarCommand(_userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpPut("profile")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request, CancellationToken ct)
{
var result = await _sender.Send(new UpdateProfileCommand(_userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetUser(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetUserQuery(id), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
}
@@ -1,25 +0,0 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов для конфигурации приложения.
/// </summary>
public sealed class ConfigEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Routes.ApiConfig);
group.MapGet("/", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Config.Queries.GetPublicConfigQuery(), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
}
}
@@ -1,41 +0,0 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using System;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов федерации.
/// </summary>
public sealed class FederationEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Routes.ApiFederation);
group.MapPost("/handshake", async ([FromBody] Host.Application.Federation.Commands.HandshakeRequest request, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Federation.Commands.HandshakeFederationCommand(request), ct);
if (result.IsSuccess)
{
return Results.Ok(result.Value);
}
if (result.Error.Code == "Unauthorized")
{
return Results.Forbid();
}
if (result.Error.Code == Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin)
{
return Results.StatusCode(503);
}
return Results.BadRequest(new { error = result.Error.Description });
});
}
}
@@ -1,34 +0,0 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using System;
using System.Threading.Tasks;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов для сервиса Klipy.
/// </summary>
public sealed class KlipyEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Routes.ApiKlipy).RequireAuthorization();
group.MapGet("/trending", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Klipy.Queries.GetTrendingGifsQuery(), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("/search", async ([FromQuery] string q, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Klipy.Queries.SearchGifsQuery(q), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
}
}
@@ -1,26 +0,0 @@
using Knot.Shared.Kernel.Configuration;
namespace Host.Models;
public record PublicConfigDto
{
public bool EnableCalls { get; init; }
public bool EnableKlipy { get; init; }
public int MaxFileSizeMb { get; init; }
public int MaxGroupMembers { get; init; }
public bool EnableConfederation { get; init; }
public bool EnableRegistration { get; init; }
public static PublicConfigDto FromSettings(SystemSettingsDto settings)
{
return new PublicConfigDto
{
EnableCalls = settings.EnableCalls,
EnableKlipy = settings.EnableKlipy,
MaxFileSizeMb = settings.MaxFileSizeMb,
MaxGroupMembers = settings.MaxGroupMembers,
EnableConfederation = settings.EnableConfederation,
EnableRegistration = settings.EnableRegistration
};
}
}
@@ -1,13 +0,0 @@
using System;
namespace Host.Models;
public record FriendDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen,
Guid FriendshipId
);
@@ -1,16 +0,0 @@
using System;
namespace Host.Models;
public record FriendRequestDto(
Guid Id,
FriendUserDto User,
DateTime CreatedAt
);
public record FriendUserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar
);
@@ -1,5 +0,0 @@
using System;
namespace Host.Models;
public record SendFriendRequest(Guid FriendId);
@@ -1,5 +0,0 @@
using System;
namespace Host.Models;
public record AddStoryReactionRequest(string Emoji);
@@ -1,5 +0,0 @@
using System;
namespace Host.Models;
public record AddStoryReplyRequest(string Content);
@@ -1,5 +0,0 @@
using System;
namespace Host.Models;
public record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor);
@@ -1,5 +0,0 @@
using System;
namespace Host.Models;
public record RemoveStoryReactionRequest(string Emoji);
@@ -1,10 +0,0 @@
using System;
namespace Host.Models;
public record StoryReactionDto(
Guid Id,
Guid UserId,
string Emoji,
DateTime CreatedAt
);
@@ -1,5 +0,0 @@
using System;
namespace Host.Models;
public sealed record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
@@ -1,3 +0,0 @@
namespace Host.Models;
public sealed record UpdateSettingsRequest(bool? HideStoryViews);
@@ -1,12 +0,0 @@
using System;
namespace Host.Models;
public record UserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen
);
@@ -1,16 +0,0 @@
using System;
namespace Host.Models;
public record UserProfileDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
string? Bio,
DateTime? Birthday,
DateTime CreatedAt,
bool? HideStoryViews = null,
bool IsOnline = false,
DateTime? LastSeen = null
);
@@ -1,10 +0,0 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Application.Abstractions;
/// <summary>
/// Unit of Work специфичный для модуля Chats.
/// </summary>
public interface IChatsUnitOfWork : IUnitOfWork
{
}
@@ -1,148 +0,0 @@
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.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.GetChats;
public record GetChatsQuery(Guid UserId) : IQuery<List<ChatDto>>;
internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<ChatDto>>
{
private readonly IChatRepository _chatRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IMessageRepository _messageRepository;
public GetChatsQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository)
{
_chatRepository = chatRepository;
_userProvider = userProvider;
_messageRepository = messageRepository;
}
public async Task<Result<List<ChatDto>>> Handle(GetChatsQuery request, CancellationToken cancellationToken)
{
var userChats = await _chatRepository.GetUserChatsAsync(request.UserId, cancellationToken);
var dtos = new List<ChatDto>();
bool hasFavorites = false;
foreach (var chat in userChats)
{
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
if (latestMessage == null)
{
continue;
}
var userIdsToFetch = new HashSet<Guid>();
foreach (var member in chat.Members)
{
userIdsToFetch.Add(member.UserId);
}
userIdsToFetch.Add(latestMessage.SenderId);
foreach (var r in latestMessage.Reactions)
{
userIdsToFetch.Add(r.UserId);
}
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var members = new List<ChatMemberDto>();
foreach (var member in chat.Members)
{
usersInfo.TryGetValue(member.UserId, out var user);
members.Add(new ChatMemberDto(
member.Id,
member.UserId,
member.Role,
member.IsPinned,
user != null ? new ChatUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false,
DateTime.UtcNow
) : null
));
}
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
var reactionsWithUser = new List<ReactionDto>();
foreach (var reaction in latestMessage.Reactions)
{
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
var messagesList = new List<ChatMessageDto>
{
new ChatMessageDto(
latestMessage.Id,
latestMessage.ChatId,
latestMessage.SenderId,
latestMessage.Content,
latestMessage.Type,
latestMessage.ReplyToId,
latestMessage.Quote,
latestMessage.StoryId,
latestMessage.StoryMediaUrl,
latestMessage.StoryMediaType,
latestMessage.IsEdited,
latestMessage.IsDeleted,
latestMessage.CreatedAt,
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senderObj != null ? new MessageSenderDto(
senderObj.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
)
};
var unreadCount = await _messageRepository.GetUnreadCountAsync(chat.Id, request.UserId, cancellationToken);
dtos.Add(new ChatDto(
chat.Id,
chat.Type.ToString().ToLowerInvariant(),
chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
chat.Description,
chat.Avatar,
chat.CreatedAt,
members,
messagesList,
unreadCount
));
}
if (!hasFavorites)
{
var favs = new ChatDto(Guid.Empty, "favorites", "Избранное", null, null, DateTime.UtcNow, new List<ChatMemberDto>(), new List<ChatMessageDto>(), 0);
dtos.Add(favs);
}
var sorted = dtos.OrderByDescending(d => d.Messages.FirstOrDefault()?.CreatedAt ?? d.CreatedAt).ToList();
return Result.Success(sorted);
}
}
@@ -1,53 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.LeaveOrDelete;
public record LeaveOrDeleteChatCommand(Guid ChatId, Guid UserId) : ICommand<SuccessResponse>;
internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrDeleteChatCommand, SuccessResponse>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public LeaveOrDeleteChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null)
{
return Result.Success(new SuccessResponse(true));
}
if (!chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<SuccessResponse>(ChatErrors.Unauthorized);
}
if (chat.Type == ChatType.Group)
{
chat.RemoveMember(request.UserId);
_chatRepository.Update(chat);
}
else
{
_chatRepository.Remove(chat);
}
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(new SuccessResponse(true));
}
}
@@ -1,24 +0,0 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record ChatMessageDto(
Guid Id,
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
Guid? ReplyToId,
string? Quote,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
bool IsEdited,
bool IsDeleted,
DateTime CreatedAt,
List<MediaDto> Media,
MessageSenderDto Sender,
List<ReactionDto> Reactions,
List<ReadByDto> ReadBy
);
@@ -1,5 +0,0 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public sealed record CreatePersonalChatRequest(Guid UserId);
@@ -1,11 +0,0 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record MediaDto(
Guid Id,
string Type,
string Url,
string? Filename,
long? Size
);
@@ -1,3 +0,0 @@
namespace Knot.Modules.Chats.Application.DTOs;
public record TogglePinResponse(bool IsPinned);
@@ -1,139 +0,0 @@
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.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.GetMessages;
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor) : IQuery<List<MessageDetailDto>>;
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
{
private readonly IMessageRepository _messageRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IChatRepository _chatRepository;
public GetMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider, IChatRepository chatRepository)
{
_messageRepository = messageRepository;
_userProvider = userProvider;
_chatRepository = chatRepository;
}
public async Task<Result<List<MessageDetailDto>>> Handle(GetMessagesQuery request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<List<MessageDetailDto>>(ChatErrors.ChatsForbidden);
}
DateTime? cursorDate = null;
if (!string.IsNullOrEmpty(request.Cursor) && DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
{
cursorDate = parsed.ToUniversalTime();
}
var messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, ChatConstants.DefaultMessageQueryLimit, cancellationToken);
var result = new List<MessageDetailDto>();
var userIdsToFetch = new HashSet<Guid>();
var replyMessages = new Dictionary<Guid, Message>();
// Filter messages that are deleted for the current user before processing
var filteredMessages = messages.Where(m => !m.IsDeletedForUser(request.UserId)).ToList();
foreach (var m in filteredMessages)
{
userIdsToFetch.Add(m.SenderId);
if (!m.ReplyToId.HasValue)
{
continue;
}
var replyMsg = await _messageRepository.GetByIdAsync(m.ReplyToId.Value, cancellationToken);
if (replyMsg == null)
{
continue;
}
replyMessages[replyMsg.Id] = replyMsg;
userIdsToFetch.Add(replyMsg.SenderId);
}
var senders = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
foreach (var message in messages)
{
if (message.IsDeletedForUser(request.UserId))
{
continue;
}
ReplyToMessageDto? replyToObj = null;
if (message.ReplyToId.HasValue && replyMessages.TryGetValue(message.ReplyToId.Value, out var replyMsg))
{
var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs)
? new MessageSenderDto(rs.Id, rs.Username, rs.DisplayName, rs.Avatar)
: null;
replyToObj = new ReplyToMessageDto(
replyMsg.Id,
replyMsg.Content,
replyMsg.IsDeleted,
replyMsg.Media.Select(rm => new MediaDto(rm.Id, rm.Type, rm.Url, rm.Filename, rm.Size)).ToList(),
senderObj
);
}
var reactionsWithUser = new List<MessageReactionDto>();
foreach (var reaction in message.Reactions)
{
var userObj = senders.TryGetValue(reaction.UserId, out var reactionUser)
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null);
reactionsWithUser.Add(new MessageReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
userObj
));
}
result.Add(new MessageDetailDto(
message.Id,
message.ChatId,
message.SenderId,
message.Content,
message.Type,
message.ReplyToId,
replyToObj,
message.Quote,
message.IsEdited,
message.IsDeleted,
message.CreatedAt,
message.ForwardedFromId,
message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser) ? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar) : null,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : null,
message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList(),
reactionsWithUser
));
}
return Result.Success(result);
}
}
@@ -1,34 +0,0 @@
using MediatR;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.Read;
public sealed record ReadMessagesCommand(Guid ChatId, Guid UserId, List<Guid> MessageIds) : ICommand;
public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCommand>
{
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
public ReadMessagesCommandHandler(IMessageRepository messageRepository, IChatsUnitOfWork unitOfWork)
{
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
{
if (request.MessageIds == null || !request.MessageIds.Any())
{
return Result.Success();
}
await _messageRepository.AddReadReceiptsAsync(request.UserId, request.MessageIds, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}
@@ -1,61 +0,0 @@
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.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
namespace Knot.Modules.Chats.Application.Messages.SearchMessages;
public record SearchMessagesQuery(Guid UserId, string Query, Guid? ChatId) : IQuery<List<SearchMessageDto>>;
internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQuery, List<SearchMessageDto>>
{
private readonly IMessageRepository _messageRepository;
private readonly IUserDisplayNameProvider _userProvider;
public SearchMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider)
{
_messageRepository = messageRepository;
_userProvider = userProvider;
}
public async Task<Result<List<SearchMessageDto>>> Handle(SearchMessagesQuery request, CancellationToken cancellationToken)
{
var messages = await _messageRepository.SearchMessagesAsync(request.Query, request.ChatId, request.UserId, cancellationToken);
messages = messages.Where(message => !message.IsDeletedForUser(request.UserId)).ToList();
var userIds = messages.Select(message => message.SenderId).ToList();
userIds.AddRange(messages.Where(message => message.ForwardedFromId.HasValue).Select(message => message.ForwardedFromId!.Value));
var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken);
var result = messages.Select(message => new SearchMessageDto(
message.Id,
message.ChatId,
message.SenderId,
message.Content,
message.Type,
message.ReplyToId,
message.Quote,
message.IsEdited,
message.IsDeleted,
message.CreatedAt,
message.ForwardedFromId,
null,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
message.Reactions.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList(),
message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
)).ToList();
return Result.Success(result);
}
}
@@ -1,127 +0,0 @@
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.Send;
/// <summary>
/// Команда для отправки сообщения в чат.
/// </summary>
public record AttachmentRequest(string Type, string Url, string? FileName, long? FileSize);
public sealed record SendMessageCommand(
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
List<AttachmentRequest>? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
Guid? ForwardedFromId = null,
Guid? StoryId = null,
string? StoryMediaUrl = null,
string? StoryMediaType = null) : ICommand<Guid>;
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly MediatR.IMediator _mediator;
public SendMessageCommandHandler(
IChatRepository chatRepository,
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator)
{
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_mediator = mediator;
}
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{
// 1. Проверяем существование чата
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null)
{
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
}
// 2. Проверяем, является ли отправитель участником
if (!chat.Members.Any(m => m.UserId == request.SenderId))
{
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
}
// 3. Создаем сообщение
Message message;
if (request.Type == "story_reply" || request.Type == "story_reaction")
{
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
message = new StoryMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.StoryId ?? Guid.Empty,
request.StoryMediaUrl ?? string.Empty,
parsedStoryMediaType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
else if (request.Attachments != null && request.Attachments.Any())
{
var firstAtt = request.Attachments.First();
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
message = new MediaMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
parsedType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
foreach (var att in request.Attachments)
{
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
((MediaMessage)message).AddMedia(pType, att.Url, att.FileName, att.FileSize);
}
}
else
{
message = new TextMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.Content ?? string.Empty,
request.ReplyToId,
request.Quote,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
// 4. Сохраняем
_messageRepository.Add(message);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _mediator.Publish(new MessageSentDomainEvent(
message.Id,
message.ChatId,
message.SenderId,
message.Content),
cancellationToken);
return Result.Success(message.Id);
}
}
@@ -1,93 +0,0 @@
using Knot.Modules.Chats.Domain;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AngleSharp.Html.Parser;
using AngleSharp.Dom;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Chats.Application.TelegramImport;
public static class TelegramImportState
{
public static readonly ConcurrentDictionary<Guid, string> TempZips = new();
}
public record AnalyzeImportResponseDto(Guid Token, List<string> Names);
public record AnalyzeImportCommand(Stream FileStream, string FileName) : ICommand<AnalyzeImportResponseDto>;
internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImportCommand, AnalyzeImportResponseDto>
{
public async Task<Result<AnalyzeImportResponseDto>> Handle(AnalyzeImportCommand request, CancellationToken cancellationToken)
{
if (request.FileStream == null || request.FileStream.Length == 0)
{
return Result.Failure<AnalyzeImportResponseDto>(ChatErrors.FileEmpty);
}
if (!request.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
return Result.Failure<AnalyzeImportResponseDto>(ChatErrors.FileInvalidExtension);
}
var token = Guid.NewGuid();
var tempPath = Path.Combine(Path.GetTempPath(), $"{token}.zip");
await using (var fs = new FileStream(tempPath, FileMode.Create))
{
await request.FileStream.CopyToAsync(fs, cancellationToken);
}
var names = new HashSet<string>();
using (var archive = ZipFile.OpenRead(tempPath))
{
var htmlEntries = archive.Entries
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase));
foreach (var entry in htmlEntries)
{
using var stream = entry.Open();
var parser = new HtmlParser();
var doc = parser.ParseDocument(stream);
var messageNodes = doc.QuerySelectorAll(".message");
if (messageNodes == null)
{
continue;
}
foreach (var node in messageNodes)
{
var fromNameNode = node.QuerySelector(".from_name");
if (fromNameNode != null)
{
var nameNodeText = (IElement)fromNameNode.Clone();
var innerSpans = nameNodeText.QuerySelectorAll("span");
foreach (var span in innerSpans)
{
span.Remove();
}
var name = nameNodeText.TextContent.Trim();
if (!string.IsNullOrWhiteSpace(name))
{
names.Add(name);
}
}
}
}
}
TelegramImportState.TempZips[token] = tempPath;
return Result.Success(new AnalyzeImportResponseDto(token, names.ToList()));
}
}
@@ -1,455 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Application.Chats.Create;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Microsoft.AspNetCore.SignalR;
namespace Knot.Modules.Chats.Application.TelegramImport;
public record ExecuteImportResponseDto(bool Success, int MessagesImported, Guid ChatId);
public record ExecuteImportCommand(
Guid CurrentUserId,
Guid Token,
Dictionary<string, Guid> Mapping,
string? GroupName
) : ICommand<ExecuteImportResponseDto>;
internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImportCommand, ExecuteImportResponseDto>
{
private readonly ISender _sender;
private readonly IChatsUnitOfWork _uow;
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage;
private readonly IHubContext<ChatHub> _hubContext;
public ExecuteImportCommandHandler(
ISender sender,
IChatsUnitOfWork uow,
IChatRepository chatRepository,
IMessageRepository messageRepository,
IFileStorageService fileStorage,
IHubContext<ChatHub> hubContext)
{
_sender = sender;
_uow = uow;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_fileStorage = fileStorage;
_hubContext = hubContext;
}
public async Task<Result<ExecuteImportResponseDto>> Handle(ExecuteImportCommand request, CancellationToken cancellationToken)
{
if (!TelegramImportState.TempZips.TryGetValue(request.Token, out var tempPath))
{
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportExpired);
}
if (!System.IO.File.Exists(tempPath))
{
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportMissing);
}
var myId = request.CurrentUserId;
var targetUserIds = request.Mapping.Values.Distinct().Where(id => id != Guid.Empty).ToList();
if (!targetUserIds.Contains(myId))
{
targetUserIds.Add(myId);
}
Guid chatId = Guid.Empty;
var chatMembers = targetUserIds;
if (chatMembers.Count <= 2)
{
var existingChats = await _chatRepository.GetUserChatsAsync(myId, cancellationToken);
var personalChat = existingChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.All(m => chatMembers.Contains(m.UserId)) && c.Members.Count == chatMembers.Count);
if (personalChat != null)
{
chatId = personalChat.Id;
}
else
{
var friendId = chatMembers.FirstOrDefault(id => id != myId);
if (friendId == Guid.Empty)
{
friendId = myId;
}
var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { myId, friendId });
var res = await _sender.Send(command, cancellationToken);
if (res.IsFailure)
{
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportCreateChatFailed(res.Error.Description ?? res.Error.Code));
}
chatId = res.Value;
}
}
else
{
var command = new CreateChatCommand(request.GroupName ?? "Импортированный чат", ChatType.Group, chatMembers);
var res = await _sender.Send(command, cancellationToken);
if (res.IsFailure)
{
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportCreateChatFailed(res.Error.Description ?? res.Error.Code));
}
chatId = res.Value;
}
int importedCount = 0;
using (var archive = ZipFile.OpenRead(tempPath))
{
var htmlEntries = archive.Entries
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase))
.OrderBy(e =>
{
var name = e.Name.ToLower().Replace("messages", "").Replace(".html", "");
return string.IsNullOrEmpty(name) ? 0 : int.TryParse(name, out var num) ? num : 999999;
})
.ToList();
Guid lastSenderGuid = myId;
DateTime lastCreatedAt = DateTime.UtcNow;
Dictionary<string, Guid> messageIdMap = new();
Message? lastSavedMessage = null;
foreach (var entry in htmlEntries)
{
using var stream = entry.Open();
var parser = new HtmlParser();
var doc = parser.ParseDocument(stream);
var messageNodes = doc.QuerySelectorAll(".message");
if (messageNodes == null)
{
continue;
}
var baseDir = Path.GetDirectoryName(entry.FullName)?.Replace("\\", "/") ?? "";
if (!string.IsNullOrEmpty(baseDir) && !baseDir.EndsWith("/"))
{
baseDir += "/";
}
foreach (var node in messageNodes)
{
try
{
var fromNameNode = node.QuerySelector(".from_name");
var textNode = node.QuerySelector(".text");
var dateNode = node.QuerySelector(".date[title]")
?? node.QuerySelector(".pull_right[title]")
?? node.QuerySelector("[title]");
if (fromNameNode != null)
{
var nameNodeText = (AngleSharp.Dom.IElement)fromNameNode.Clone();
var innerSpans = nameNodeText.QuerySelectorAll("span");
foreach (var span in innerSpans)
{
span.Remove();
}
var name = nameNodeText.TextContent.Trim();
if (request.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty)
{
lastSenderGuid = mappedId;
}
else
{
lastSenderGuid = myId;
}
}
Guid senderGuid = lastSenderGuid;
string content = "";
var mainBodyNode = node.QuerySelector(".body");
var isForwarded = node.QuerySelector(".forwarded") != null;
var contentTextNode = isForwarded
? (node.QuerySelector(".body > .text") ?? node.QuerySelector(".text:not(.forwarded .text)"))
: node.QuerySelector(".text");
if (contentTextNode != null)
{
var html = contentTextNode.InnerHtml
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
var tempParser = new HtmlParser();
var tempDoc = tempParser.ParseDocument("<div>" + html + "</div>");
content = tempDoc.Body?.TextContent.Trim() ?? "";
}
DateTime createdAt = lastCreatedAt;
var titleNodes = node.QuerySelectorAll("[title]");
bool parsed = false;
if (titleNodes != null)
{
foreach (var tnode in titleNodes)
{
var dateStr = tnode.GetAttribute("title")?.Trim() ?? "";
if (dateStr.Length >= 10 && char.IsDigit(dateStr[0]) && char.IsDigit(dateStr[1]))
{
var cleanStr = dateStr.Replace("UTC", "", StringComparison.OrdinalIgnoreCase).Trim();
if (DateTimeOffset.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto))
{
createdAt = dto.UtcDateTime;
parsed = true;
break;
}
else if (DateTime.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt))
{
createdAt = dt;
parsed = true;
break;
}
else if (DateTime.TryParse(cleanStr, out var dFallback))
{
createdAt = dFallback.ToUniversalTime();
parsed = true;
break;
}
}
}
}
if (!parsed)
{
Console.WriteLine("Warning: Could not parse date in imported message! Using lastCreatedAt.");
}
else
{
lastCreatedAt = createdAt;
}
Guid? forwardedFromId = null;
var forwardedNode = node.QuerySelector(".forwarded.body");
if (forwardedNode != null)
{
var fwdNameNode = forwardedNode.QuerySelector(".from_name");
var fwdNameText = fwdNameNode != null ? (AngleSharp.Dom.IElement)fwdNameNode.Clone() : null;
if (fwdNameText != null)
{
var innerSpans = fwdNameText.QuerySelectorAll("span");
foreach (var s in innerSpans)
{
s.Remove();
}
}
var fwdName = fwdNameText != null ? fwdNameText.TextContent.Trim() : "Неизвестного";
if (request.Mapping.TryGetValue(fwdName, out var mappedFwdId) && mappedFwdId != Guid.Empty)
{
forwardedFromId = mappedFwdId;
}
else if (fwdName == "Это я" || fwdName == request.Mapping.FirstOrDefault(x => x.Value == myId).Key)
{
forwardedFromId = myId;
}
var fwdTextNode = forwardedNode.QuerySelector(".text");
string fwdContent = "";
if (fwdTextNode != null)
{
var fHtml = fwdTextNode.InnerHtml
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
var tempParser = new HtmlParser();
var tempDoc = tempParser.ParseDocument("<div>" + fHtml + "</div>");
fwdContent = tempDoc.Body?.TextContent.Trim() ?? "";
}
if (forwardedFromId == null)
{
content = string.IsNullOrEmpty(content)
? $"[Переслано от {fwdName}]:\n{fwdContent}"
: $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}";
}
else if (string.IsNullOrEmpty(content))
{
content = fwdContent;
}
}
Guid? replyToId = null;
var replyNode = node.QuerySelector(".reply_to a");
if (replyNode != null)
{
var href = replyNode.GetAttribute("href");
if (href != null && href.StartsWith("#go_to_"))
{
var tgId = href.Substring("#go_to_".Length);
if (messageIdMap.TryGetValue(tgId, out var mappedMsgId))
{
replyToId = mappedMsgId;
}
}
}
var mediaNodes = node.QuerySelectorAll("a.photo_wrap, a.animated_wrap, video, audio, a.document, a.media_voice_message, a.media_video, img.sticker").ToList();
if (mediaNodes.Count == 0)
{
var fallback = node.QuerySelectorAll(".media_wrap a[href]");
mediaNodes.AddRange(fallback);
}
var messageType = "text";
(string mType, string cType) GetMediaTypes(string fileUrl)
{
var ext = Path.GetExtension(fileUrl)?.ToLower();
return ext switch
{
".jpg" or ".jpeg" or ".png" or ".webp" => ("image", "image/jpeg"),
".mp4" or ".mov" or ".avi" => ("video", "video/mp4"),
".ogg" or ".mp3" => ("voice", "audio/ogg"),
_ => ("file", "application/octet-stream")
};
}
if (mediaNodes != null && mediaNodes.Count > 0)
{
var firstHref = mediaNodes[0].GetAttribute("href") ?? mediaNodes[0].GetAttribute("src");
if (firstHref != null)
{
messageType = GetMediaTypes(firstHref).mType;
if (mediaNodes[0].ClassName?.Contains("animated") == true || firstHref.EndsWith(".mp4"))
{
if (mediaNodes[0].ClassName?.Contains("animated") == true)
{
messageType = "image";
}
}
}
}
bool isJoined = fromNameNode == null;
bool isMediaOnly = string.IsNullOrEmpty(content) && forwardedNode == null && replyToId == null;
bool hasMedia = mediaNodes != null && mediaNodes.Count > 0;
Message? targetMessage = null;
if (isJoined && isMediaOnly && lastSavedMessage is MediaMessage && Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60 && lastSavedMessage.SenderId == senderGuid)
{
targetMessage = lastSavedMessage;
}
else
{
string finalContent = content;
if (hasMedia)
{
targetMessage = new MediaMessage(Guid.NewGuid(), chatId, senderGuid, MediaType.File, finalContent, replyToId, forwardedFromId, createdAt, true);
}
else
{
targetMessage = new TextMessage(Guid.NewGuid(), chatId, senderGuid, finalContent, replyToId, null, forwardedFromId, createdAt, true);
}
var idAttr = node.GetAttribute("id");
if (!string.IsNullOrEmpty(idAttr))
{
messageIdMap[idAttr] = targetMessage.Id;
}
}
if (hasMedia)
{
foreach (var mediaNode in mediaNodes!)
{
string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src");
if (!string.IsNullOrEmpty(href) && !href.StartsWith("http"))
{
var zipPath = baseDir + href.Replace("\\", "/");
var zipEntry = archive.GetEntry(zipPath);
if (zipEntry != null)
{
using var ms = new MemoryStream();
using var zipfs = zipEntry.Open();
await zipfs.CopyToAsync(ms, cancellationToken);
ms.Position = 0;
var types = GetMediaTypes(href);
var finalMType = types.mType;
if (mediaNode.ClassName?.Contains("animated") == true)
{
finalMType = "image";
}
var parsedType = Enum.TryParse<MediaType>(finalMType, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
if (targetMessage is MediaMessage mm)
{
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType);
mm.AddMedia(parsedType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length);
}
}
}
}
}
var reactionNodes = node.QuerySelectorAll(".reactions .reaction");
foreach (var reactionNode in reactionNodes)
{
var emojiNode = reactionNode.QuerySelector(".emoji");
if (emojiNode == null)
{
continue;
}
var emoji = emojiNode.TextContent.Trim();
var userpicNodes = reactionNode.QuerySelectorAll(".userpics .userpic .initials[title]");
foreach (var userpicNode in userpicNodes)
{
var title = userpicNode.GetAttribute("title")?.Trim();
if (!string.IsNullOrEmpty(title) && request.Mapping.TryGetValue(title, out var rUserId) && rUserId != Guid.Empty)
{
targetMessage.AddReaction(rUserId, emoji);
}
}
}
if (targetMessage != lastSavedMessage && (!string.IsNullOrEmpty(content) || targetMessage.Media.Any()))
{
_messageRepository.Add(targetMessage);
lastSavedMessage = targetMessage;
importedCount++;
}
}
catch { /* ignore single message parse error */ }
}
}
}
await _uow.SaveChangesAsync(cancellationToken);
try { System.IO.File.Delete(tempPath); TelegramImportState.TempZips.TryRemove(request.Token, out _); } catch { }
await _hubContext.Clients.Users(chatMembers.Select(x => x.ToString())).SendAsync("history_updated", new { chatId });
return Result.Success(new ExecuteImportResponseDto(true, importedCount, chatId));
}
}
@@ -1,48 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using MongoDB.Driver;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence.Mongo;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats;
public static class DependencyInjection
{
/// <summary>
/// Регистрация сервисов модуля Chats.
/// </summary>
public static IServiceCollection AddChatsModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Настройка базы данных
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<ChatsDbContext>(options =>
options.UseNpgsql(connectionString));
// MongoDB Setup for Messages
MongoDbMapConfigurator.Configure();
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
services.AddSingleton<IMongoClient>(new MongoClient(mongoConnectionString));
services.AddScoped<IMongoDatabase>(sp =>
sp.GetRequiredService<IMongoClient>().GetDatabase("forkmessager_chats"));
// Registration
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatRepository, ChatRepository>();
services.AddScoped<IMessageRepository, MessageRepository>();
// MediatR
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
return services;
}
}
@@ -1,9 +0,0 @@
namespace Knot.Modules.Chats.Domain;
public static class ChatConstants
{
public const int DefaultMessageQueryLimit = 100;
public const int MaxSharedMediaQueryLimit = 300;
public const int SearchMessagesLimit = 50;
public const int MaxFileUploadSizeMb = 50;
}
@@ -1,19 +0,0 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
public static class ChatErrors
{
public static readonly Error FileEmpty = new Error("File.Empty", "No file uploaded");
public static readonly Error FileInvalidExtension = new Error("File.InvalidExtension", "Must be a ZIP archive");
public static readonly Error ImportExpired = new Error("Import.Expired", "Session not found or expired");
public static readonly Error ImportMissing = new Error("Import.Missing", "ZIP file lost");
public static readonly Error ChatNotFound = new Error("Chat.NotFound", "Chat not found or access denied");
public static readonly Error ChatsForbidden = new Error("Chats.Forbidden", "Вы не являетесь участником этого чата.");
public static readonly Error MessagesNotFound = new Error("Messages.NotFound", "Message not found.");
public static readonly Error ChatsNotFound = new Error("Chats.NotFound", "Чат не найден.");
public static readonly Error Unauthorized = new Error("Chats.Unauthorized", "Access denied");
public static Error ImportCreateChatFailed(string msg) => new Error("Import.CreateChatFailed", msg);
public static Error FileTooLarge(int maxMb) => new Error("File.TooLarge", $"File exceeds the maximum allowed size of {maxMb}MB.");
}
@@ -1,13 +0,0 @@
using Knot.Modules.Chats.Domain;
namespace Knot.Modules.Chats.Domain;
public interface IChatRepository
{
void Add(Chat chat);
void Update(Chat chat);
void Remove(Chat chat);
Task<Chat?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<Chat?> GetFavoritesAsync(Guid userId, CancellationToken cancellationToken);
Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken);
}
@@ -1,55 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Chats.Domain;
namespace Knot.Modules.Chats.Infrastructure.Persistence;
public sealed class ChatRepository : IChatRepository
{
private readonly ChatsDbContext _dbContext;
public ChatRepository(ChatsDbContext dbContext)
{
_dbContext = dbContext;
}
public void Add(Chat chat)
{
_dbContext.Chats.Add(chat);
}
public void Update(Chat chat)
{
_dbContext.Chats.Update(chat);
}
public void Remove(Chat chat)
{
_dbContext.Chats.Remove(chat);
}
public async Task<Chat?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await _dbContext.Chats
.Include(c => c.Members)
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
}
public async Task<Chat?> GetFavoritesAsync(Guid userId, CancellationToken cancellationToken)
{
return await _dbContext.Chats
.Include(c => c.Members)
.FirstOrDefaultAsync(c =>
c.Type == ChatType.Favorites &&
c.Members.Any(m => m.UserId == userId),
cancellationToken);
}
public async Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken)
{
return await _dbContext.Chats
.Include(c => c.Members)
.Where(c => c.Members.Any(m => m.UserId == userId))
.OrderByDescending(c => c.CreatedAt)
.ToListAsync(cancellationToken);
}
}
@@ -1,87 +0,0 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Chats.Infrastructure.Persistence;
/// <summary>
/// Контекст базы данных для модуля чатов.
/// </summary>
public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
{
private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService;
public ChatsDbContext(DbContextOptions<ChatsDbContext> options, IMediator mediator, IEncryptionService encryptionService)
: base(options)
{
_mediator = mediator;
_encryptionService = encryptionService;
}
public DbSet<Chat> Chats => Set<Chat>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("chats");
modelBuilder.Entity<Chat>(builder =>
{
builder.ToTable("Chats");
builder.HasKey(c => c.Id);
builder.Property(c => c.Type).HasConversion<string>();
builder.OwnsMany(c => c.Members, mb =>
{
mb.ToTable("ChatMembers");
mb.HasKey(m => m.Id);
mb.WithOwner().HasForeignKey(m => m.ChatId);
mb.HasIndex(m => new { m.ChatId, m.UserId }).IsUnique();
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
});
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. Получаем все события из агрегатов
var domainEvents = ChangeTracker
.Entries<IAggregateRoot>()
.SelectMany(x =>
{
if (x.Entity is AggregateRoot<Guid> root)
{
var events = root.GetDomainEvents().ToList();
root.ClearDomainEvents();
return events;
}
return Enumerable.Empty<IDomainEvent>();
})
.ToList();
// 2. Сохраняем изменения
int result = await base.SaveChangesAsync(cancellationToken);
// 3. Публикуем события через MediatR
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
return result;
}
}
@@ -1,8 +0,0 @@
using Knot.Modules.Identity.Domain;
namespace Knot.Modules.Identity.Application.Abstractions;
public interface IJwtTokenProvider
{
string Generate(User user);
}
@@ -1,36 +0,0 @@
using Knot.Modules.Identity.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Application.Friends;
public record AcceptFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand<Guid>;
internal sealed class AcceptFriendRequestCommandHandler : ICommandHandler<AcceptFriendRequestCommand, Guid>
{
private readonly IIdentityDbContext _context;
private readonly IIdentityUnitOfWork _unitOfWork;
public AcceptFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(AcceptFriendRequestCommand request, CancellationToken cancellationToken)
{
var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken);
if (friendship == null || friendship.FriendId != request.UserId)
{
return Result.Failure<Guid>(IdentityErrors.FriendsNotFound);
}
friendship.Accept();
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(friendship.Id);
}
}
@@ -1,36 +0,0 @@
using Knot.Modules.Identity.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Application.Friends;
public record DeclineFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand;
internal sealed class DeclineFriendRequestCommandHandler : ICommandHandler<DeclineFriendRequestCommand>
{
private readonly IIdentityDbContext _context;
private readonly IIdentityUnitOfWork _unitOfWork;
public DeclineFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(DeclineFriendRequestCommand request, CancellationToken cancellationToken)
{
var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken);
if (friendship == null || friendship.FriendId != request.UserId)
{
return Result.Failure(IdentityErrors.FriendsNotFound);
}
friendship.Decline();
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}
@@ -1,14 +0,0 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Identity.Application.Friends;
public record FriendDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeenAt,
Guid FriendshipId
);
@@ -1,11 +0,0 @@
using System;
namespace Knot.Modules.Identity.Application.Friends;
public record FriendUserDto(Guid Id, string Username, string DisplayName, string? Avatar);
public record FriendRequestDto(
Guid Id,
FriendUserDto User,
DateTime CreatedAt
);
@@ -1,9 +0,0 @@
using System;
namespace Knot.Modules.Identity.Application.Friends;
public record FriendshipStatusResponse(
string Status,
Guid? FriendshipId = null,
string? Direction = null
);
@@ -1,59 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Identity.Application.Friends;
public record GetFriendsQuery(Guid UserId) : IQuery<List<FriendDto>>;
internal sealed class GetFriendsQueryHandler : IQueryHandler<GetFriendsQuery, List<FriendDto>>
{
private readonly IIdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetFriendsQueryHandler(IIdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<FriendDto>>> Handle(GetFriendsQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Friendships
.Where(f => (f.UserId == request.UserId || f.FriendId == request.UserId) && f.Status == FriendshipStatus.Accepted)
.ToListAsync(cancellationToken);
var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList();
var friends = new List<FriendDto>();
foreach (var id in friendIds)
{
var user = await _userRepository.GetByIdAsync(id, cancellationToken);
if (user == null)
{
continue;
}
var fs = friendships.First(f => f.UserId == id || f.FriendId == id);
friends.Add(new FriendDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false, // IsOnline logic shouldn't be here, but we'll leave default for now
DateTime.UtcNow,
fs.Id
));
}
return Result.Success(friends);
}
}
@@ -1,43 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Identity.Application.Friends;
public record GetFriendshipStatusQuery(Guid CurrentUserId, Guid TargetUserId) : IQuery<FriendshipStatusResponse>;
internal sealed class GetFriendshipStatusQueryHandler : IQueryHandler<GetFriendshipStatusQuery, FriendshipStatusResponse>
{
private readonly IIdentityDbContext _context;
public GetFriendshipStatusQueryHandler(IIdentityDbContext context)
{
_context = context;
}
public async Task<Result<FriendshipStatusResponse>> Handle(GetFriendshipStatusQuery request, CancellationToken cancellationToken)
{
if (request.CurrentUserId == request.TargetUserId)
{
return Result.Success(new FriendshipStatusResponse("self"));
}
var fs = await _context.Friendships
.FirstOrDefaultAsync(f => (f.UserId == request.CurrentUserId && f.FriendId == request.TargetUserId) ||
(f.UserId == request.TargetUserId && f.FriendId == request.CurrentUserId), cancellationToken);
if (fs == null)
{
return Result.Success(new FriendshipStatusResponse("none"));
}
return Result.Success(new FriendshipStatusResponse(
fs.Status.ToString().ToLowerInvariant(),
fs.Id,
fs.UserId == request.CurrentUserId ? "outgoing" : "incoming"
));
}
}
@@ -1,49 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Identity.Application.Friends;
public record GetIncomingRequestsQuery(Guid UserId) : IQuery<List<FriendRequestDto>>;
internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler<GetIncomingRequestsQuery, List<FriendRequestDto>>
{
private readonly IIdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetIncomingRequestsQueryHandler(IIdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<FriendRequestDto>>> Handle(GetIncomingRequestsQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Friendships
.Where(f => f.FriendId == request.UserId && f.Status == FriendshipStatus.Pending)
.ToListAsync(cancellationToken);
var requestsList = new List<FriendRequestDto>();
foreach (var fs in friendships)
{
var user = await _userRepository.GetByIdAsync(fs.UserId, cancellationToken);
if (user == null)
{
continue;
}
requestsList.Add(new FriendRequestDto(
fs.Id,
new FriendUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
fs.CreatedAt
));
}
return Result.Success(requestsList);
}
}
@@ -1,49 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Identity.Application.Friends;
public record GetOutgoingRequestsQuery(Guid UserId) : IQuery<List<FriendRequestDto>>;
internal sealed class GetOutgoingRequestsQueryHandler : IQueryHandler<GetOutgoingRequestsQuery, List<FriendRequestDto>>
{
private readonly IIdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetOutgoingRequestsQueryHandler(IIdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<FriendRequestDto>>> Handle(GetOutgoingRequestsQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Friendships
.Where(f => f.UserId == request.UserId && f.Status == FriendshipStatus.Pending)
.ToListAsync(cancellationToken);
var requestsList = new List<FriendRequestDto>();
foreach (var fs in friendships)
{
var user = await _userRepository.GetByIdAsync(fs.FriendId, cancellationToken);
if (user == null)
{
continue;
}
requestsList.Add(new FriendRequestDto(
fs.Id,
new FriendUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
fs.CreatedAt
));
}
return Result.Success(requestsList);
}
}
@@ -1,36 +0,0 @@
using Knot.Modules.Identity.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Application.Friends;
public record RemoveFriendCommand(Guid UserId, Guid FriendshipId) : ICommand;
internal sealed class RemoveFriendCommandHandler : ICommandHandler<RemoveFriendCommand>
{
private readonly IIdentityDbContext _context;
private readonly IIdentityUnitOfWork _unitOfWork;
public RemoveFriendCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(RemoveFriendCommand request, CancellationToken cancellationToken)
{
var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken);
if (friendship == null || (friendship.UserId != request.UserId && friendship.FriendId != request.UserId))
{
return Result.Failure(IdentityErrors.FriendsNotFound);
}
_context.Friendships.Remove(friendship);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}
@@ -1,46 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Identity.Application.Friends;
public record SendFriendRequestCommand(Guid UserId, Guid FriendId) : ICommand;
internal sealed class SendFriendRequestCommandHandler : ICommandHandler<SendFriendRequestCommand>
{
private readonly IIdentityDbContext _context;
private readonly IIdentityUnitOfWork _unitOfWork;
public SendFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(SendFriendRequestCommand request, CancellationToken cancellationToken)
{
if (request.UserId == request.FriendId)
{
return Result.Failure(IdentityErrors.FriendsSelf);
}
var existing = await _context.Friendships
.FirstOrDefaultAsync(f => (f.UserId == request.UserId && f.FriendId == request.FriendId) ||
(f.UserId == request.FriendId && f.FriendId == request.UserId), cancellationToken);
if (existing != null)
{
return Result.Failure(IdentityErrors.FriendsExists);
}
var friendship = Friendship.Create(request.UserId, request.FriendId);
_context.Friendships.Add(friendship);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}
@@ -1,68 +0,0 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Knot.Modules.Identity.Application.Users.Avatar;
public sealed record CropAvatarCommand(Guid UserId, Stream FileStream, string FileName, string ContentType, int X, int Y, int Width, int Height) : ICommand<UserProfileDto>;
internal sealed class CropAvatarCommandHandler : ICommandHandler<CropAvatarCommand, UserProfileDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
private readonly IFileStorageService _fileStorage;
public CropAvatarCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork, IFileStorageService fileStorage)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_fileStorage = fileStorage;
}
public async Task<Result<UserProfileDto>> Handle(CropAvatarCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
string avatarUrl;
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(request.FileStream, cancellationToken))
{
int startX = Math.Max(0, Math.Min(request.X, image.Width - 1));
int startY = Math.Max(0, Math.Min(request.Y, image.Height - 1));
int rectWidth = Math.Max(1, Math.Min(request.Width, image.Width - startX));
int rectHeight = Math.Max(1, Math.Min(request.Height, image.Height - startY));
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
image.Mutate(ctx => ctx.Resize(400, 400));
using var outStream = new MemoryStream();
await image.SaveAsJpegAsync(outStream, cancellationToken);
outStream.Position = 0;
var id = await _fileStorage.UploadFileAsync(outStream, request.FileName, "image/jpeg");
avatarUrl = $"/api/files/{id}";
}
user.UpdateAvatar(avatarUrl);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt
);
return Result.Success(dto);
}
}
@@ -1,43 +0,0 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Identity.Application.Users.Avatar;
public sealed record DeleteAvatarCommand(Guid UserId) : ICommand<UserProfileDto>;
internal sealed class DeleteAvatarCommandHandler : ICommandHandler<DeleteAvatarCommand, UserProfileDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
public DeleteAvatarCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<UserProfileDto>> Handle(DeleteAvatarCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
user.UpdateAvatar(null);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt
);
return Result.Success(dto);
}
}
@@ -1,49 +0,0 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
namespace Knot.Modules.Identity.Application.Users.Avatar;
public sealed record UploadAvatarCommand(Guid UserId, Stream FileStream, string FileName, string ContentType) : ICommand<UserProfileDto>;
internal sealed class UploadAvatarCommandHandler : ICommandHandler<UploadAvatarCommand, UserProfileDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
private readonly IFileStorageService _fileStorage;
public UploadAvatarCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork, IFileStorageService fileStorage)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_fileStorage = fileStorage;
}
public async Task<Result<UserProfileDto>> Handle(UploadAvatarCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType);
var avatarUrl = $"/api/files/{fileId}";
user.UpdateAvatar(avatarUrl);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt
);
return Result.Success(dto);
}
}
@@ -1,41 +0,0 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Identity.Application.Users.GetUser;
public sealed record GetUserQuery(Guid Id) : IQuery<UserProfileDto>;
internal sealed class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserProfileDto>
{
private readonly IUserRepository _userRepository;
public GetUserQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<UserProfileDto>> Handle(GetUserQuery request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.Id, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt,
null,
false,
DateTime.UtcNow
);
return Result.Success(dto);
}
}
@@ -1,87 +0,0 @@
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Identity.Application.Users.Auth;
using BCrypt.Net;
namespace Knot.Modules.Identity.Application.Users.Register;
/// <summary>
/// Команда для регистрации нового пользователя.
/// </summary>
public sealed record RegisterUserCommand(
string Username,
string Password,
string DisplayName,
string? Email,
string? Bio) : ICommand<AuthResponseDto>;
/// <summary>
/// Обработчик команды регистрации.
/// </summary>
public sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
private readonly ISettingsService _settings;
private readonly IJwtTokenProvider _tokenProvider;
public RegisterUserCommandHandler(
IUserRepository userRepository,
IIdentityUnitOfWork unitOfWork,
ISettingsService settings,
IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_settings = settings;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
{
if (!_settings.Current.EnableRegistration)
{
return Result.Failure<AuthResponseDto>(IdentityErrors.IdentityRegistrationDisabled);
}
// 1. Проверка уникальности username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{
return Result.Failure<AuthResponseDto>(IdentityErrors.IdentityUsernameNotUnique);
}
// 2. Хеширование пароля (здесь будет вызов сервиса, пока заглушка)
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3. Создание сущности
var user = User.Create(
request.Username,
passwordHash,
request.DisplayName,
request.Email,
request.Bio);
// 4. Сохранение
_userRepository.Add(user);
await _unitOfWork.SaveChangesAsync(cancellationToken);
string token = _tokenProvider.Generate(user);
return Result.Success(new AuthResponseDto(
token,
new AuthUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Bio,
user.Avatar,
user.Birthday,
true, // IsOnline (placeholder)
user.CreatedAt
)
));
}
}
@@ -1,33 +0,0 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Identity.Application.Users.Search;
public sealed record SearchUsersQuery(string Query) : IQuery<List<UserDto>>;
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<UserDto>>
{
private readonly IUserRepository _userRepository;
public SearchUsersQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<List<UserDto>>> Handle(SearchUsersQuery request, CancellationToken cancellationToken)
{
var users = await _userRepository.SearchUsersAsync(request.Query, cancellationToken);
var result = users.Select(user => new UserDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false,
DateTime.UtcNow
)).ToList();
return Result.Success(result);
}
}
@@ -1,43 +0,0 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Identity.Application.Users.UpdateProfile;
public sealed record UpdateProfileCommand(Guid UserId, string? DisplayName, string? Bio, DateTime? Birthday) : ICommand<UserProfileDto>;
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
public UpdateProfileCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<UserProfileDto>> Handle(UpdateProfileCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
user.UpdateProfile(request.DisplayName ?? user.DisplayName, request.Bio, request.Birthday);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt
);
return Result.Success(dto);
}
}
@@ -1,44 +0,0 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Identity.Application.Users.UpdateSettings;
public sealed record UpdateSettingsCommand(Guid UserId, bool? HideStoryViews) : ICommand<UserProfileDto>;
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, UserProfileDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
public UpdateSettingsCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<UserProfileDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
user.UpdateSettings(request.HideStoryViews ?? user.HideStoryViews);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt,
user.HideStoryViews
);
return Result.Success(dto);
}
}
@@ -1,12 +0,0 @@
using System;
namespace Knot.Modules.Identity.Application.Users;
public record UserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen
);
@@ -1,16 +0,0 @@
using System;
namespace Knot.Modules.Identity.Application.Users;
public record UserProfileDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
string? Bio,
DateTime? Birthday,
DateTime CreatedAt,
bool? HideStoryViews = null,
bool IsOnline = false,
DateTime? LastSeen = null
);
@@ -1,40 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Infrastructure.Authentication;
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity;
public static class DependencyInjection
{
/// <summary>
/// Регистрация сервисов модуля Identity.
/// </summary>
public static IServiceCollection AddIdentityModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Настройка базы данных
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<IdentityDbContext>(options =>
options.UseNpgsql(connectionString));
// Регистрация Unit of Work и Репозиториев
services.AddScoped<IIdentityUnitOfWork>(sp => sp.GetRequiredService<IdentityDbContext>());
services.AddScoped<IIdentityDbContext>(sp => sp.GetRequiredService<IdentityDbContext>());
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IJwtTokenProvider, JwtTokenProvider>();
services.AddScoped<IUserDisplayNameProvider, Knot.Modules.Identity.Infrastructure.Services.UserDisplayNameProvider>();
// Регистрация MediatR для этого модуля
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
return services;
}
}
@@ -1,17 +0,0 @@
using Knot.Modules.Identity.Domain;
namespace Knot.Modules.Identity.Domain;
/// <summary>
/// Интерфейс репозитория для работы с пользователями.
/// </summary>
public interface IUserRepository
{
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<List<User>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default);
Task<User?> GetByUsernameAsync(string username, CancellationToken cancellationToken = default);
Task<bool> IsUsernameUniqueAsync(string username, CancellationToken cancellationToken = default);
Task<List<User>> SearchUsersAsync(string query, CancellationToken cancellationToken = default);
void Add(User user);
void Update(User user);
}
@@ -1,74 +0,0 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Domain;
public class Story : Entity<Guid>
{
public Guid UserId { get; private set; }
public string Type { get; private set; } // text, image, video
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; }
private readonly List<StoryViewer> _viewers = new();
private readonly List<StoryReaction> _reactions = new();
private readonly List<StoryReply> _replies = new();
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)
{
UserId = userId;
Type = type;
MediaUrl = mediaUrl;
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)
{
return new Story(Guid.NewGuid(), userId, type, mediaUrl, content, bgColor);
}
public void AddViewer(Guid userId)
{
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));
}
}
@@ -1,21 +0,0 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.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.Identity.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;
}
}
@@ -1,60 +0,0 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Domain;
/// <summary>
/// Сущность пользователя в контексте идентификации (Identity).
/// </summary>
public sealed class User : AggregateRoot<Guid>
{
public string Username { get; private set; }
public string PasswordHash { get; private set; }
public string DisplayName { get; private set; }
public string? Email { get; private set; }
public string? Bio { get; private set; }
public string? Avatar { get; private set; }
public DateTime? Birthday { get; private set; }
public DateTime CreatedAt { get; private set; }
public bool HideStoryViews { get; private set; }
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
: base(id)
{
Username = username;
PasswordHash = passwordHash;
DisplayName = displayName;
Email = email;
Bio = bio;
CreatedAt = DateTime.UtcNow;
}
/// <summary>
/// Фабричный метод для создания нового пользователя.
/// </summary>
public static User Create(string username, string passwordHash, string displayName, string? email = null, string? bio = null)
{
return new User(Guid.NewGuid(), username, passwordHash, displayName, email, bio);
}
public void UpdateProfile(string displayName, string? bio, DateTime? birthday)
{
DisplayName = displayName;
Bio = bio;
Birthday = birthday;
}
public void UpdateAvatar(string? avatarUrl)
{
Avatar = avatarUrl;
}
public void UpdateSettings(bool hideStoryViews)
{
HideStoryViews = hideStoryViews;
}
public void ChangePassword(string newPasswordHash)
{
PasswordHash = newPasswordHash;
}
}
@@ -1,45 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
namespace Knot.Modules.Identity.Infrastructure.Authentication;
public sealed class JwtTokenProvider : IJwtTokenProvider
{
private readonly IConfiguration _configuration;
public JwtTokenProvider(IConfiguration configuration)
{
_configuration = configuration;
}
public string Generate(User user)
{
var claims = new Claim[]
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.UniqueName, user.Username),
new("name", user.DisplayName),
new(ClaimTypes.Name, user.DisplayName),
new("avatar", user.Avatar ?? string.Empty)
};
var secretKey = _configuration["Jwt:Secret"]!;
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
_configuration["Jwt:Issuer"],
_configuration["Jwt:Audience"],
claims,
null,
DateTime.UtcNow.AddMinutes(double.Parse(_configuration["Jwt:ExpiryInMinutes"] ?? "1440")),
credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -1,157 +0,0 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Metadata;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Identity.Infrastructure.Persistence;
/// <summary>
/// Контекст базы данных для модуля Identity.
/// </summary>
public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork, Knot.Modules.Identity.Application.Abstractions.IIdentityDbContext
{
private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService;
public IdentityDbContext(DbContextOptions<IdentityDbContext> options, IMediator mediator, IEncryptionService encryptionService)
: base(options)
{
_mediator = mediator;
_encryptionService = encryptionService;
}
public DbSet<User> Users => Set<User>();
public DbSet<Story> Stories => Set<Story>();
public DbSet<StoryViewer> StoryViewers => Set<StoryViewer>();
public DbSet<StoryReaction> StoryReactions => Set<StoryReaction>();
public DbSet<StoryReply> StoryReplies => Set<StoryReply>();
public DbSet<Friendship> Friendships => Set<Friendship>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("identity");
modelBuilder.Entity<Friendship>(builder =>
{
builder.ToTable("Friendships");
builder.HasKey(f => f.Id);
builder.HasIndex(f => new { f.UserId, f.FriendId }).IsUnique();
});
modelBuilder.Entity<Story>(builder =>
{
builder.ToTable("Stories");
builder.HasKey(s => s.Id);
builder.Property(s => s.Type).IsRequired();
builder.Property(s => s.Content)
.HasConversion(
v => v == null ? null : _encryptionService.EncryptMessage(v),
v => v == null ? null : _encryptionService.DecryptMessage(v)!
);
// Configure backing fields for collections
builder.Metadata.FindNavigation(nameof(Story.Viewers))?.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.Metadata.FindNavigation(nameof(Story.Reactions))?.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.Metadata.FindNavigation(nameof(Story.Replies))?.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.HasMany(s => s.Viewers)
.WithOne()
.HasForeignKey(v => v.StoryId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(s => s.Reactions)
.WithOne()
.HasForeignKey(r => r.StoryId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(s => s.Replies)
.WithOne()
.HasForeignKey(r => r.StoryId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<StoryViewer>(builder =>
{
builder.ToTable("StoryViewers");
builder.HasKey(v => v.Id);
builder.HasIndex(v => new { v.StoryId, v.UserId }).IsUnique();
});
modelBuilder.Entity<StoryReaction>(builder =>
{
builder.ToTable("StoryReactions");
builder.HasKey(r => r.Id);
builder.Property(r => r.Emoji).IsRequired().HasMaxLength(10);
builder.HasIndex(r => new { r.StoryId, r.UserId, r.Emoji }).IsUnique();
});
modelBuilder.Entity<StoryReply>(builder =>
{
builder.ToTable("StoryReplies");
builder.HasKey(r => r.Id);
builder.Property(r => r.Content)
.IsRequired()
.HasMaxLength(500)
.HasConversion(
v => _encryptionService.EncryptMessage(v) ?? string.Empty,
v => _encryptionService.DecryptMessage(v) ?? string.Empty
);
});
modelBuilder.Entity<User>(builder =>
{
builder.ToTable("Users");
builder.HasKey(u => u.Id);
builder.Property(u => u.Username).IsRequired().HasMaxLength(50);
builder.HasIndex(u => u.Username).IsUnique();
builder.Property(u => u.PasswordHash).IsRequired();
builder.Property(u => u.DisplayName).HasMaxLength(100);
builder.Property(u => u.Email).HasMaxLength(255);
builder.Property(u => u.Bio).HasMaxLength(500);
builder.Property(u => u.Avatar).HasMaxLength(500);
builder.Property(u => u.Birthday);
builder.Property(u => u.CreatedAt).IsRequired();
builder.Property(u => u.HideStoryViews).HasDefaultValue(false);
});
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var domainEvents = ChangeTracker
.Entries<IAggregateRoot>()
.SelectMany(x =>
{
if (x.Entity is AggregateRoot<Guid> root)
{
var events = root.GetDomainEvents().ToList();
root.ClearDomainEvents();
return events;
}
return Enumerable.Empty<IDomainEvent>();
})
.ToList();
int result = await base.SaveChangesAsync(cancellationToken);
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
return result;
}
}
@@ -1,120 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class UpdateIdentityWithNewFieldsAndTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Avatar",
schema: "identity",
table: "Users",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Bio",
schema: "identity",
table: "Users",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "Birthday",
schema: "identity",
table: "Users",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
schema: "identity",
table: "Users",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.CreateTable(
name: "Friendships",
schema: "identity",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
FriendId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Friendships", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Stories",
schema: "identity",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
MediaUrl = table.Column<string>(type: "text", nullable: true),
Content = table.Column<string>(type: "text", nullable: true),
BgColor = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Stories", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Friendships_UserId_FriendId",
schema: "identity",
table: "Friendships",
columns: new[] { "UserId", "FriendId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Friendships",
schema: "identity");
migrationBuilder.DropTable(
name: "Stories",
schema: "identity");
migrationBuilder.DropColumn(
name: "Avatar",
schema: "identity",
table: "Users");
migrationBuilder.DropColumn(
name: "Bio",
schema: "identity",
table: "Users");
migrationBuilder.DropColumn(
name: "Birthday",
schema: "identity",
table: "Users");
migrationBuilder.DropColumn(
name: "CreatedAt",
schema: "identity",
table: "Users");
}
}
}
@@ -1,141 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Identity.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(IdentityDbContext))]
[Migration("20260311215347_AddHideStoryViewsToUser")]
partial class AddHideStoryViewsToUser
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("identity")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Identity.Domain.Friendship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FriendId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "FriendId")
.IsUnique();
b.ToTable("Friendships", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.Story", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BgColor")
.HasColumnType("text");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("Stories", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("HideStoryViews")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,178 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Identity.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(IdentityDbContext))]
[Migration("20260312174453_AddHideStoryViewsAndFixStoryViewer")]
partial class AddHideStoryViewsAndFixStoryViewer
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("identity")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Identity.Domain.Friendship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FriendId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "FriendId")
.IsUnique();
b.ToTable("Friendships", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.Story", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BgColor")
.HasColumnType("text");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("Stories", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryViewer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<DateTime>("ViewedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("StoryId", "UserId")
.IsUnique();
b.ToTable("StoryViewers", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("HideStoryViews")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryViewer", b =>
{
b.HasOne("Knot.Modules.Identity.Domain.Story", null)
.WithMany("Viewers")
.HasForeignKey("StoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.Story", b =>
{
b.Navigation("Viewers");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,255 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Identity.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(IdentityDbContext))]
[Migration("20260313115319_AddStoryReactionsReplies")]
partial class AddStoryReactionsReplies
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("identity")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Identity.Domain.Friendship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FriendId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "FriendId")
.IsUnique();
b.ToTable("Friendships", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.Story", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BgColor")
.HasColumnType("text");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("Stories", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryReaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Emoji")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("StoryId", "UserId", "Emoji")
.IsUnique();
b.ToTable("StoryReactions", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryReply", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("StoryId");
b.ToTable("StoryReplies", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryViewer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<DateTime>("ViewedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("StoryId", "UserId")
.IsUnique();
b.ToTable("StoryViewers", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("HideStoryViews")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryReaction", b =>
{
b.HasOne("Knot.Modules.Identity.Domain.Story", null)
.WithMany("Reactions")
.HasForeignKey("StoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryReply", b =>
{
b.HasOne("Knot.Modules.Identity.Domain.Story", null)
.WithMany("Replies")
.HasForeignKey("StoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryViewer", b =>
{
b.HasOne("Knot.Modules.Identity.Domain.Story", null)
.WithMany("Viewers")
.HasForeignKey("StoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.Story", b =>
{
b.Navigation("Reactions");
b.Navigation("Replies");
b.Navigation("Viewers");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,86 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStoryReactionsReplies : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "StoryReactions",
schema: "identity",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
StoryId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Emoji = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StoryReactions", x => x.Id);
table.ForeignKey(
name: "FK_StoryReactions_Stories_StoryId",
column: x => x.StoryId,
principalSchema: "identity",
principalTable: "Stories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "StoryReplies",
schema: "identity",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
StoryId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Content = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StoryReplies", x => x.Id);
table.ForeignKey(
name: "FK_StoryReplies_Stories_StoryId",
column: x => x.StoryId,
principalSchema: "identity",
principalTable: "Stories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_StoryReactions_StoryId_UserId_Emoji",
schema: "identity",
table: "StoryReactions",
columns: new[] { "StoryId", "UserId", "Emoji" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_StoryReplies_StoryId",
schema: "identity",
table: "StoryReplies",
column: "StoryId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StoryReactions",
schema: "identity");
migrationBuilder.DropTable(
name: "StoryReplies",
schema: "identity");
}
}
}
@@ -1,252 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Knot.Modules.Identity.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(IdentityDbContext))]
partial class IdentityDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("identity")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Identity.Domain.Friendship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FriendId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "FriendId")
.IsUnique();
b.ToTable("Friendships", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.Story", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BgColor")
.HasColumnType("text");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("Stories", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryReaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Emoji")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("StoryId", "UserId", "Emoji")
.IsUnique();
b.ToTable("StoryReactions", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryReply", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("StoryId");
b.ToTable("StoryReplies", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryViewer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<DateTime>("ViewedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("StoryId", "UserId")
.IsUnique();
b.ToTable("StoryViewers", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("HideStoryViews")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryReaction", b =>
{
b.HasOne("Knot.Modules.Identity.Domain.Story", null)
.WithMany("Reactions")
.HasForeignKey("StoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryReply", b =>
{
b.HasOne("Knot.Modules.Identity.Domain.Story", null)
.WithMany("Replies")
.HasForeignKey("StoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.StoryViewer", b =>
{
b.HasOne("Knot.Modules.Identity.Domain.Story", null)
.WithMany("Viewers")
.HasForeignKey("StoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Knot.Modules.Identity.Domain.Story", b =>
{
b.Navigation("Reactions");
b.Navigation("Replies");
b.Navigation("Viewers");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,56 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Identity.Domain;
namespace Knot.Modules.Identity.Infrastructure.Persistence;
/// <summary>
/// Реализация репозитория пользователей с использованием EF Core.
/// </summary>
public sealed class UserRepository : IUserRepository
{
private readonly IdentityDbContext _context;
public UserRepository(IdentityDbContext context)
{
_context = context;
}
public async Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Users.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
}
public async Task<List<User>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default)
{
return await _context.Users.Where(u => ids.Contains(u.Id)).ToListAsync(cancellationToken);
}
public async Task<User?> GetByUsernameAsync(string username, CancellationToken cancellationToken = default)
{
return await _context.Users.FirstOrDefaultAsync(u => u.Username == username, cancellationToken);
}
public async Task<bool> IsUsernameUniqueAsync(string username, CancellationToken cancellationToken = default)
{
return !await _context.Users.AnyAsync(u => u.Username == username, cancellationToken);
}
public async Task<List<User>> SearchUsersAsync(string query, CancellationToken cancellationToken = default)
{
return await _context.Users
.Where(u => u.Username.ToLower().Contains(query.ToLower()) ||
(u.DisplayName != null && u.DisplayName.ToLower().Contains(query.ToLower())))
.Take(20)
.ToListAsync(cancellationToken);
}
public void Add(User user)
{
_context.Users.Add(user);
}
public void Update(User user)
{
_context.Users.Update(user);
}
}
@@ -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;

Some files were not shown because too many files have changed in this diff Show More