Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8399d32490 | ||
|
|
3905094ff4 | ||
|
|
9e8625aea1 | ||
|
|
5b905c94da | ||
|
|
0eecc01374 | ||
|
|
c37e1723d4 | ||
|
|
02043d4d97 | ||
|
|
852efa090e | ||
|
|
c45f4db61c | ||
|
|
02a85fc587 | ||
|
|
e09860700c | ||
|
|
32c9bc43cf | ||
|
|
d96e4ec7d4 | ||
|
|
1558b20470 | ||
|
|
fa185afc73 | ||
|
|
90096ce2bc | ||
|
|
65d5f5fee9 | ||
|
|
e11240f78f | ||
|
|
53f193970c | ||
|
|
f41ad0bcf8 | ||
|
|
b49ff47762 | ||
|
|
182bbe2ad8 | ||
|
|
a04f04a448 | ||
|
|
2ab5b295e8 | ||
|
|
1b8abbc995 | ||
|
|
943699139b | ||
|
|
757142eda7 | ||
|
|
a42007df2d | ||
|
|
9df7d7aaf1 | ||
|
|
4ae7dd60ce | ||
|
|
249c344df8 | ||
|
|
2c6d6f831f | ||
|
|
bce8f92101 | ||
|
|
aa69c44a64 | ||
|
|
4faa7561a0 | ||
|
|
8025340e45 | ||
|
|
60d3cf12ae | ||
|
|
b09e197626 | ||
|
|
f1f8f6e012 | ||
|
|
d3f1e3f361 | ||
|
|
ce212c11c1 | ||
|
|
143c0b7dc4 | ||
|
|
cb93ff7240 | ||
|
|
09e5cbaa76 | ||
|
|
465e84e686 | ||
|
|
44d59dab2a | ||
|
|
b719c50413 | ||
|
|
9438cf1b35 | ||
|
|
89b2eeea6c | ||
|
|
0209802e9e | ||
|
|
22bc964f27 | ||
|
|
9bae9752cc | ||
|
|
ba5c5210d4 | ||
|
|
030ae1e4e4 | ||
|
|
28fb8c25de | ||
|
|
11f2b232a3 | ||
|
|
16978c423c | ||
|
|
b399649cfd | ||
|
|
7ef73b414c | ||
|
|
7cb6ac61dd | ||
|
|
6e532b021d | ||
|
|
5da1a2f45d | ||
|
|
f3e0941647 | ||
|
|
473e9bcef6 | ||
|
|
a48a0b0977 | ||
|
|
24f029c9ed | ||
|
|
1f8746bd03 | ||
|
|
74cae687a5 | ||
|
|
71667f8948 | ||
|
|
17d02beb30 | ||
|
|
77912f2d88 | ||
|
|
275aa0ee7d | ||
|
|
4384233aa5 | ||
|
|
11ebbc853b | ||
|
|
fb252f9d87 |
@@ -2,9 +2,6 @@ node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
apps/web/dist
|
||||
apps/server/dist
|
||||
apps/server/uploads
|
||||
.vscode
|
||||
.env
|
||||
bin/
|
||||
|
||||
@@ -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;"]
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
-26
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
-72
@@ -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)));
|
||||
}
|
||||
}
|
||||
-52
@@ -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));
|
||||
}
|
||||
}
|
||||
-141
@@ -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;
|
||||
}
|
||||
}
|
||||
-124
@@ -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;
|
||||
}
|
||||
}
|
||||
-36
@@ -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);
|
||||
}
|
||||
}
|
||||
-39
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
-66
@@ -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);
|
||||
}
|
||||
}
|
||||
-65
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-53
@@ -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);
|
||||
-139
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-34
@@ -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();
|
||||
}
|
||||
}
|
||||
-61
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-127
@@ -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);
|
||||
}
|
||||
}
|
||||
-87
@@ -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);
|
||||
}
|
||||
}
|
||||
-44
@@ -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;
|
||||
}
|
||||
}
|
||||
-45
@@ -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;
|
||||
}
|
||||
}
|
||||
-120
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-141
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-178
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-255
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-86
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-252
@@ -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
Reference in New Issue
Block a user