Разделение

This commit is contained in:
Халимов Рустам
2026-03-30 15:35:28 +03:00
parent 465e84e686
commit 09e5cbaa76
57 changed files with 631 additions and 233 deletions

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Auth.Abstractions;
public interface IUserQueryService
{
Task<List<UserInfo>> GetAllUsersAsync(CancellationToken cancellationToken);
Task<List<UserInfo>> SearchUsersAsync(string query, CancellationToken cancellationToken);
}
public record UserInfo(Guid Id, string? Avatar, string? UserName, string? DisplayName, DateTime CreatedAt, string? PhoneNumber, string? Email);

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Knot.Contracts.Auth.Domain;
using Microsoft.EntityFrameworkCore;
namespace Knot.Contracts.Auth.Application.Abstractions;

View File

@@ -1,6 +0,0 @@
using Knot.Contracts.Auth.Domain;
using MediatR;
namespace Knot.Contracts.Auth.Application.Users.GetMe;
public record GetMeQuery() : IRequest<UserContract>;

View File

@@ -1,10 +0,0 @@
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Contracts.Auth.Application.Users.Login;
public record LoginUserCommand(
string Username,
string Password
) : IRequest<Result<AuthResponseDto>>;

View File

@@ -1,14 +0,0 @@
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Contracts.Auth.Application.Users.Register;
public record RegisterUserCommand(
string Username,
string Password,
string DisplayName,
string? Email,
string? Bio
) : IRequest<Result<AuthResponseDto>>;

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Cleanup.Abstractions;
public interface ICleanupService
{
Task<CleanupData> GetCleanupDataAsync(CancellationToken cancellationToken);
Task CleanOrphanMessagesAsync(List<Guid> messageIds, CancellationToken cancellationToken);
Task CleanOrphanFilesAsync(List<string> fileIds, CancellationToken cancellationToken);
}
public record CleanupData(
List<ChatCleanupInfo> Chats,
List<MessageCleanupInfo> Messages,
List<StoryCleanupInfo> Stories,
List<UserCleanupInfo> Users,
List<FileCleanupInfo> Files);
public record ChatCleanupInfo(Guid Id, string? Avatar);
public record MessageCleanupInfo(Guid Id, Guid ChatId, bool IsDeleted, string? MediaUrl);
public record StoryCleanupInfo(Guid Id, string? MediaUrl);
public record UserCleanupInfo(Guid Id, string? Avatar);
public record FileCleanupInfo(string FileId);

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Conversations.Abstractions;
public interface IChatQueryService
{
Task<List<ChatInfo>> GetAllChatsAsync(CancellationToken cancellationToken);
}
public interface IUserDeleterService
{
Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
}
public record ChatInfo(Guid Id, string? Avatar);

View File

@@ -0,0 +1,6 @@
namespace Knot.Contracts.Conversations.Abstractions;
public interface IUserStatusService
{
bool IsUserOnline(string userId);
}

View File

@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.SignalR;
namespace Knot.Contracts.Conversations.Application.Abstractions;
public interface IStoryNotificationsClient
{
Task StoryViewed(Guid storyId, Guid userId);
Task StoryReactionAdded(Guid storyId, Guid userId, string reaction);
Task StoryReactionRemoved(Guid storyId, Guid userId);
}
public interface IChatHubClient
{
Task MessageReceived(object message);
Task MessageDeleted(Guid messageId);
Task MessageEdited(Guid messageId, object newContent);
Task ReactionAdded(Guid messageId, string reaction, Guid userId);
Task ReactionRemoved(Guid messageId, string reaction, Guid userId);
Task UserStatusChanged(Guid userId, bool isOnline);
Task ChatUpdated(object chat);
Task ChatDeleted(Guid chatId);
Task UserJoinedChat(Guid chatId, Guid userId);
Task UserLeftChat(Guid chatId, Guid userId);
}

View File

@@ -0,0 +1,20 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Contracts.Conversations.Infrastructure.Persistence;
public interface IChatsDbContext : IChatsUnitOfWork
{
IQueryable<Chat> Chats { get; }
}
public class Chat : AggregateRoot<Guid>
{
public Chat() : base(Guid.NewGuid()) { }
public string? Avatar { get; set; }
}

View File

@@ -11,6 +11,10 @@
<ProjectReference Include="..\Messaging\Knot.Contracts.Messaging.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />

View File

@@ -0,0 +1,6 @@
namespace Knot.Contracts.Klipy.Abstractions;
public interface IKlipyClient
{
Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Messaging.Abstractions;
public interface IMessageQueryService
{
Task<List<MessageInfo>> GetAllMessagesAsync(CancellationToken cancellationToken);
Task DeleteMessagesAsync(List<Guid> messageIds, CancellationToken cancellationToken);
}
public record MessageInfo(Guid Id, Guid ChatId, bool IsDeleted, string? MediaUrl);

View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Messaging.Abstractions;
public interface IUserStatsService
{
Task<Dictionary<Guid, UserStats>> GetStatsForUsersAsync(List<Guid> userIds, CancellationToken cancellationToken = default);
}
public record UserStats(int MessageCount, long StorageSize);

View File

@@ -18,4 +18,5 @@ public interface IMessageRepository
Task DeleteChatMessagesAsync(Guid chatId, CancellationToken cancellationToken);
Task DeleteUserMessagesAsync(Guid userId, CancellationToken cancellationToken);
Task RemoveAsync(Guid id, CancellationToken cancellationToken);
Task DeleteAsync(Message message, CancellationToken cancellationToken);
}

View File

@@ -1,16 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Messaging.Application.Abstractions;
public record UserStats(int MessageCount, long StorageSize);
public interface IUserStatsService
{
Task<Dictionary<Guid, UserStats>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default);
Task<long> GetTotalStorageSizeAsync(CancellationToken ct = default);
Task<int> GetCountOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken ct = default);
Task<long> GetOrphanedMediaSizeAsync(HashSet<string> validFileIds, CancellationToken ct = default);
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Messaging.Infrastructure.Persistence;
public interface IMongoMessageCollection
{
Task<List<Message>> GetAllAsync(CancellationToken cancellationToken);
Task<List<Message>> GetOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken cancellationToken);
Task DeleteManyAsync(List<Guid> messageIds, CancellationToken cancellationToken);
}
public class Message
{
public Guid Id { get; set; }
public Guid ChatId { get; set; }
public bool IsDeleted { get; set; }
public string? MediaUrl { get; set; }
public List<Media>? Media { get; set; }
}
public class Media
{
public string Url { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Relations.Domain;
namespace Knot.Contracts.Relations.Application.Abstractions;
public interface IFriendshipRepository
{
Task<List<Friendship>> GetAcceptedFriendshipsAsync(Guid userId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,11 @@
namespace Knot.Contracts.Relations.Domain;
using System;
public record Friendship(
Guid Id,
Guid UserId,
Guid FriendId,
FriendshipStatus Status,
DateTime CreatedAt
);

View File

@@ -0,0 +1,8 @@
namespace Knot.Contracts.Relations.Domain;
public enum FriendshipStatus
{
Pending,
Accepted,
Declined
}

View File

@@ -0,0 +1,12 @@
namespace Knot.Contracts.Relations.Domain;
using System;
public record UserReplica(
Guid Id,
string Username,
string DisplayName,
string Avatar,
bool IsExternal,
string? Domain
);

View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Stories.Abstractions;
public interface IStoryQueryService
{
Task<List<StoryInfo>> GetAllStoriesAsync(CancellationToken cancellationToken);
}
public record StoryInfo(Guid Id, string? MediaUrl);

View File

@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="MongoDB.Driver" Version="3.2.0" />
</ItemGroup>
</Project>

View File

@@ -14,12 +14,12 @@
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\..\Contracts\Stories\Knot.Contracts.Stories.csproj" />
<ProjectReference Include="..\..\Contracts\Klipy\Knot.Contracts.Klipy.csproj" />
<!-- Admin needs direct module access for cleanup operations -->
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" />
<ProjectReference Include="..\Klipy\Knot.Modules.Klipy.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
<!-- Admin needs direct module access for cleanup operations - PrivateAssets prevents module exposure -->
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Klipy\Knot.Modules.Klipy.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Auth.Abstractions;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Auth.Infrastructure.Persistence;
public sealed class UserQueryService : IUserQueryService
{
private readonly AuthDbContext _context;
public UserQueryService(AuthDbContext context)
{
_context = context;
}
public async Task<List<UserInfo>> GetAllUsersAsync(CancellationToken cancellationToken)
{
var users = await _context.Users.AsNoTracking().ToListAsync(cancellationToken);
return users.Select(u => new UserInfo(u.Id, u.Avatar, u.Username, u.DisplayName, u.CreatedAt, u.PhoneNumber, u.Email)).ToList();
}
public async Task<List<UserInfo>> SearchUsersAsync(string query, CancellationToken cancellationToken)
{
var users = await _context.Users
.AsNoTracking()
.Where(u => u.Username != null && u.Username.Contains(query) ||
u.DisplayName != null && u.DisplayName.Contains(query) ||
u.Email != null && u.Email.Contains(query) ||
u.PhoneNumber != null && u.PhoneNumber.Contains(query))
.Take(50)
.ToListAsync(cancellationToken);
return users.Select(u => new UserInfo(u.Id, u.Avatar, u.Username, u.DisplayName, u.CreatedAt, u.PhoneNumber, u.Email)).ToList();
}
}

View File

@@ -0,0 +1,11 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Abstractions;
public interface IUserDeleterService
{
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
}

View File

@@ -1,3 +1,4 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
@@ -8,6 +9,7 @@ using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ConversationsAbstractions = Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations;
@@ -22,28 +24,22 @@ public static class DependencyInjection
services.AddDbContext<ChatsDbContext>(options =>
options.UseNpgsql(connectionString));
// MongoDB Setup for Messages
ConversationsMongoDbMapConfigurator.Configure();
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
// Registration
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<ConversationsAbstractions.IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatRepository, ChatRepository>();
services.AddScoped<IFolderRepository, FolderRepository>();
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
services.AddScoped<IUserFolderSettingsRepository, UserFolderSettingsRepository>();
// Messaging Repository - registered in Messaging module
// services.AddScoped<IMessageRepository, MessageRepository>();
// MediatR
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>();
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
services.AddScoped<IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
services.AddScoped<ConversationsAbstractions.IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
return services;
}
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Abstractions;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
public sealed class ChatQueryService : IChatQueryService
{
private readonly ChatsDbContext _context;
public ChatQueryService(ChatsDbContext context)
{
_context = context;
}
public async Task<List<ChatInfo>> GetAllChatsAsync(CancellationToken cancellationToken)
{
var chats = await _context.Chats.AsNoTracking().ToListAsync(cancellationToken);
return chats.Select(c => new ChatInfo(c.Id, c.Avatar)).ToList();
}
}

View File

@@ -1,22 +1,23 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
using System.Linq;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
/// <summary>
/// Контекст базы данных для модуля чатов.
/// </summary>
public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork
{
private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService;
@@ -69,7 +70,7 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
builder.ToTable("UserChatSettings");
builder.HasKey(s => s.Id);
builder.HasIndex(s => new { s.UserId, s.ChatId }).IsUnique();
builder.Property(s => s.FolderIds)
.HasConversion(
v => string.Join(',', v),

View File

@@ -12,7 +12,6 @@
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
<ProjectReference Include="..\..\Contracts\Stories\Knot.Contracts.Stories.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />

View File

@@ -8,7 +8,6 @@
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />

View File

@@ -1,5 +1,6 @@
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Infrastructure.Persistence;
using Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
using Knot.Shared.Kernel;
@@ -24,12 +25,10 @@ public static class DependencyInjection
services.AddScoped<IMongoDatabase>(sp =>
sp.GetRequiredService<IMongoClient>().GetDatabase("forkmessager_chats"));
// Registration
services.AddScoped<IMessageRepository, MessageRepository>();
services.AddScoped<IMessageReactionRepository, MessageReactionRepository>();
services.AddScoped<IUserStatsService, UserStatsService>();
// MediatR
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Messaging.Abstractions;
using Knot.Contracts.Messaging.Domain;
using MongoDB.Driver;
namespace Knot.Modules.Messaging.Infrastructure.Persistence;
public sealed class MessageQueryService : IMessageQueryService
{
private readonly IMongoCollection<Message> _messages;
public MessageQueryService(IMongoDatabase database)
{
_messages = database.GetCollection<Message>("messages");
}
public async Task<List<MessageInfo>> GetAllMessagesAsync(CancellationToken cancellationToken)
{
var messages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
return messages.Select(m => new MessageInfo(
m.Id,
m.ChatId,
m.State.HasFlag(MessageState.IsDeleted),
m is MediaMessage mm && mm.Media.Any() ? mm.Media.First().Url : null
)).ToList();
}
public async Task DeleteMessagesAsync(List<Guid> messageIds, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.In(m => m.Id, messageIds);
await _messages.DeleteManyAsync(filter, cancellationToken);
}
}

View File

@@ -153,6 +153,12 @@ public sealed class MessageRepository : IMessageRepository
var filter = Builders<Message>.Filter.Eq(m => m.Id, id);
await _messages.DeleteOneAsync(filter, cancellationToken);
}
public async Task DeleteAsync(Message message, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.Id, message.Id);
await _messages.DeleteOneAsync(filter, cancellationToken);
}
}

View File

@@ -3,14 +3,16 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Messaging.Application.Abstractions;
using MongoDB.Bson;
using MongoDB.Driver;
using ModuleUserStatsService = Knot.Modules.Messaging.Application.Abstractions.IUserStatsService;
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
public sealed class UserStatsService : IUserStatsService
public sealed class UserStatsService : ModuleUserStatsService
{
private readonly IMongoCollection<Message> _messages;
@@ -19,13 +21,13 @@ public sealed class UserStatsService : IUserStatsService
_messages = database.GetCollection<Message>("messages");
}
public async Task<Dictionary<Guid, UserStats>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
public async Task<Dictionary<Guid, Knot.Modules.Messaging.Application.Abstractions.UserStats>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
{
var userGuidList = userIds.ToList();
if (!userGuidList.Any()) return new Dictionary<Guid, UserStats>();
var userIdList = userIds.ToList();
if (!userIdList.Any()) return new Dictionary<Guid, Knot.Modules.Messaging.Application.Abstractions.UserStats>();
var stats = await _messages.Aggregate()
.Match(Builders<Message>.Filter.In(m => m.SenderId, userGuidList))
.Match(Builders<Message>.Filter.In(m => m.SenderId, userIdList))
.Group(new BsonDocument {
{ "_id", "$SenderId" },
{ "Count", new BsonDocument("$sum", 1) },
@@ -35,7 +37,7 @@ public sealed class UserStatsService : IUserStatsService
return stats.ToDictionary(
doc => doc["_id"].AsGuid,
doc => new UserStats(
doc => new Knot.Modules.Messaging.Application.Abstractions.UserStats(
doc["Count"].AsInt32,
doc.Contains("MediaSize") && !doc["MediaSize"].IsBsonNull ? (long)(doc["MediaSize"].IsInt64 ? doc["MediaSize"].AsInt64 : doc["MediaSize"].AsInt32) : 0L
)

View File

@@ -3,13 +3,13 @@
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
<PackageReference Include="MongoDB.Driver" Version="3.2.0" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
<PropertyGroup>

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Relations.Application.Abstractions;
using Knot.Contracts.Relations.Domain;
using Knot.Modules.Relations.Domain;
using Knot.Modules.Relations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Relations.Application.Abstractions;
internal sealed class FriendshipRepository : IFriendshipRepository
{
private readonly RelationsDbContext _context;
public FriendshipRepository(RelationsDbContext context)
{
_context = context;
}
public async Task<List<Knot.Contracts.Relations.Domain.Friendship>> GetAcceptedFriendshipsAsync(Guid userId, CancellationToken ct = default)
{
var friendships = await _context.Set<Knot.Modules.Relations.Domain.Friendship>()
.Where(f => f.Status == Knot.Contracts.Relations.Domain.FriendshipStatus.Accepted && (f.UserId == userId || f.FriendId == userId))
.ToListAsync(ct);
return friendships.Select(f => new Knot.Contracts.Relations.Domain.Friendship(
f.Id,
f.UserId,
f.FriendId,
f.Status,
f.CreatedAt
)).ToList();
}
}

View File

@@ -1,7 +1,9 @@
using Knot.Contracts.Relations.Application.Abstractions;
using Knot.Modules.Relations.Application.Abstractions;
using Knot.Modules.Relations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Knot.Modules.Relations.Infrastructure.Persistence;
namespace Knot.Modules.Relations;
@@ -13,6 +15,8 @@ public static class DependencyInjection
services.AddDbContext<RelationsDbContext>(options =>
options.UseNpgsql(connectionString));
services.AddScoped<IFriendshipRepository, FriendshipRepository>();
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));

View File

@@ -1,14 +1,8 @@
using Knot.Contracts.Relations.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Relations.Domain;
public enum FriendshipStatus
{
Pending,
Accepted,
Declined
}
public sealed class Friendship : Entity<Guid>
{
public Guid UserId { get; private set; }

View File

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Contracts\Relations\Knot.Contracts.Relations.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
</ItemGroup>
@@ -21,7 +22,6 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>

View File

@@ -17,7 +17,7 @@
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />
<PackageReference Include="Minio" Version="7.0.0" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,21 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Stories.Domain;
using Knot.Modules.Relations.Domain;
namespace Knot.Modules.Stories.Application.Abstractions;
public interface IStoriesDbContext
{
DbSet<Story> Stories { get; }
DbSet<Friendship> Friendships { get; }
DbSet<StoryViewer> StoryViewers { get; }
DbSet<TEntity> Set<TEntity>() where TEntity : class;
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
public interface IStoriesUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Stories.Application.Abstractions;
public interface IStoryNotificationService
{
Task NotifyStoryViewedAsync(
Guid storyId,
Guid userId,
string? username,
string? displayName,
string? avatar,
int newViewsCount,
Guid storyOwnerId,
CancellationToken ct = default);
}

View File

@@ -1,12 +1,11 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Stories.Domain;
namespace Knot.Modules.Stories.Application.Abstractions;
public interface IUserRepository
{
Task<bool> ExistsAsync(Guid id, CancellationToken cancellationToken = default);
Task<UserReplica?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<Knot.Contracts.Relations.Domain.UserReplica?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -1,21 +1,15 @@
using Knot.Modules.Stories.Application.Stories;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Stories.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Modules.Stories.Application.DTOs;
using Knot.Modules.Stories.Application.Stories;
using Knot.Modules.Stories.Domain;
using Knot.Modules.Stories.Infrastructure.Database;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Stories.Application.Stories.Commands.ViewStory;
@@ -26,18 +20,18 @@ internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand
private readonly StoriesDbContext _context;
private readonly Knot.Modules.Stories.Application.Abstractions.IStoryRepository _storyRepository;
private readonly IUserRepository _userRepository;
private readonly IHubContext<ChatHub> _hubContext;
private readonly IStoryNotificationService _notificationService;
public ViewStoryCommandHandler(
StoriesDbContext context,
StoriesDbContext context,
Knot.Modules.Stories.Application.Abstractions.IStoryRepository storyRepository,
IUserRepository userRepository,
IHubContext<ChatHub> hubContext)
IUserRepository userRepository,
IStoryNotificationService notificationService)
{
_context = context;
_storyRepository = storyRepository;
_userRepository = userRepository;
_hubContext = hubContext;
_notificationService = notificationService;
}
public async Task<Result<MessageResponse>> Handle(ViewStoryCommand request, CancellationToken cancellationToken)
@@ -60,22 +54,20 @@ internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand
{
_context.StoryViewers.Add(StoryViewer.Create(story.Id, request.UserId));
await _context.SaveChangesAsync(cancellationToken);
await _storyRepository.IncrementViewsCountAsync(story.Id, cancellationToken);
var viewer = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
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 = story.ViewsCount + 1,
ownerId = story.UserId
}, cancellationToken);
await _notificationService.NotifyStoryViewedAsync(
story.Id,
request.UserId,
viewer?.Username,
viewer?.DisplayName,
viewer?.Avatar,
story.ViewsCount + 1,
story.UserId,
cancellationToken);
}
return Result.Success(new MessageResponse("Story viewed"));
}

View File

@@ -3,18 +3,14 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Stories.Domain;
using Knot.Contracts.Relations.Application.Abstractions;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Modules.Stories.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Stories.Application.DTOs;
using Knot.Modules.Stories.Domain;
using Knot.Modules.Stories.Infrastructure.Database;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.EntityFrameworkCore;
@@ -27,20 +23,24 @@ internal sealed class GetStoriesQueryHandler : IQueryHandler<GetStoriesQuery, Li
private readonly StoriesDbContext _context;
private readonly IStoryRepository _storyRepository;
private readonly IUserRepository _userRepository;
private readonly IFriendshipRepository _friendshipRepository;
public GetStoriesQueryHandler(StoriesDbContext context, IStoryRepository storyRepository, IUserRepository userRepository)
public GetStoriesQueryHandler(
StoriesDbContext context,
IStoryRepository storyRepository,
IUserRepository userRepository,
IFriendshipRepository friendshipRepository)
{
_context = context;
_storyRepository = storyRepository;
_userRepository = userRepository;
_friendshipRepository = friendshipRepository;
}
public async Task<Result<List<StoryGroupDto>>> Handle(GetStoriesQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Set<Friendship>()
.Where(f => f.Status == FriendshipStatus.Accepted && (f.UserId == request.UserId || f.FriendId == request.UserId))
.ToListAsync(cancellationToken);
var friendships = await _friendshipRepository.GetAcceptedFriendshipsAsync(request.UserId, cancellationToken);
var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList();
friendIds.Add(request.UserId);
@@ -89,14 +89,14 @@ internal sealed class GetStoriesQueryHandler : IQueryHandler<GetStoriesQuery, Li
));
}
return Result.Success(result.OrderBy(r =>
return Result.Success(result.OrderBy(r =>
{
if (r.User.Id == request.UserId)
{
if (r.User.Id == request.UserId)
{
return 0;
}
return 1;
}).ToList());
return 0;
}
return 1;
}).ToList());
}
}

View File

@@ -2,15 +2,12 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Modules.Stories.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Stories.Application.DTOs;
using Knot.Modules.Stories.Infrastructure.Database;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.EntityFrameworkCore;
@@ -69,7 +66,7 @@ internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQ
return Result.Success(result);
}
private async Task<Result<StoryGroupDto>> GetFederatedStories(Knot.Modules.Relations.Domain.UserReplica user, Guid currentUserId, CancellationToken ct)
private async Task<Result<StoryGroupDto>> GetFederatedStories(Knot.Contracts.Relations.Domain.UserReplica user, Guid currentUserId, CancellationToken ct)
{
// В будущем: Реальный вызов через HttpClient к удаленному домену
// return await _federationGateway.FetchStoriesAsync(user.Domain, user.Id);

View File

@@ -1,3 +1,2 @@
global using Knot.Modules.Stories.Application.Abstractions;
global using Knot.Modules.Stories.Domain;
global using Knot.Modules.Relations.Domain;

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Stories.Abstractions;
using Knot.Modules.Stories.Domain;
using MongoDB.Driver;
namespace Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
public sealed class StoryQueryService : IStoryQueryService
{
private readonly IMongoCollection<Story> _stories;
public StoryQueryService(IMongoDatabase database)
{
_stories = database.GetCollection<Story>("stories");
}
public async Task<List<StoryInfo>> GetAllStoriesAsync(CancellationToken cancellationToken)
{
var stories = await _stories.Find(_ => true).ToListAsync(cancellationToken);
return stories.Select(s => new StoryInfo(s.Id, s.MediaUrl)).ToList();
}
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Knot.Modules.Stories.Application.Abstractions;
internal sealed class StoryNotificationService : IStoryNotificationService
{
private readonly ILogger<StoryNotificationService> _logger;
public StoryNotificationService(ILogger<StoryNotificationService> logger)
{
_logger = logger;
}
public Task NotifyStoryViewedAsync(
Guid storyId,
Guid userId,
string? username,
string? displayName,
string? avatar,
int newViewsCount,
Guid storyOwnerId,
CancellationToken ct = default)
{
// TODO: Реализовать отправку через SignalR
// Пока заглушка - в будущем использовать IHubContext
_logger.LogInformation(
"Story {StoryId} viewed by user {UserId}. New views count: {ViewsCount}",
storyId, userId, newViewsCount);
return Task.CompletedTask;
}
}

View File

@@ -7,10 +7,11 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Contracts\Relations\Knot.Contracts.Relations.csproj" />
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\..\Contracts\Stories\Knot.Contracts.Stories.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\Relations\Knot.Modules.Relations.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
</ItemGroup>
<ItemGroup>
@@ -24,7 +25,6 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,5 +1,3 @@
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
@@ -8,11 +6,11 @@ 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 AngleSharp.Html.Parser;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.TelegramImport.Application.TelegramImport;
@@ -25,8 +23,8 @@ public static class TelegramImportState
public record ImportConflictDto(string Type, string Message, bool Blocked);
public record AnalyzeImportResponseDto(
Guid Token,
List<string> Names,
Guid Token,
List<string> Names,
List<ImportConflictDto> Conflicts);
public record AnalyzeImportCommand(Stream FileStream, string FileName) : ICommand<AnalyzeImportResponseDto>;
@@ -43,12 +41,12 @@ internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImpor
{
if (request.FileStream == null || request.FileStream.Length == 0)
{
return Result.Failure<AnalyzeImportResponseDto>(ChatErrors.FileEmpty);
return Result.Failure<AnalyzeImportResponseDto>(TelegramImportErrors.FileEmpty);
}
if (!request.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
return Result.Failure<AnalyzeImportResponseDto>(ChatErrors.FileInvalidExtension);
return Result.Failure<AnalyzeImportResponseDto>(TelegramImportErrors.FileInvalidExtension);
}
var token = Guid.NewGuid();
@@ -104,12 +102,12 @@ internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImpor
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
var conflicts = new List<ImportConflictDto>();
if (!settings.Messages.AllowMedia)
if (!settings.Messages.AllowMedia)
conflicts.Add(new ImportConflictDto("Media", "Медиафайлы (фото/видео) отключены на сервере. Они не будут импортированы.", true));
if (!settings.Messages.AllowVoiceMessages)
conflicts.Add(new ImportConflictDto("Voice", "Голосовые сообщения запрещены администратором. Будут пропущены.", true));
if (!settings.Messages.AllowPolls)
conflicts.Add(new ImportConflictDto("Polls", "Опросы не поддерживаются текущими настройками сервера.", true));

View File

@@ -0,0 +1,7 @@
namespace Knot.Modules.TelegramImport.Application.TelegramImport;
public static class TelegramImportErrors
{
public static readonly Knot.Shared.Kernel.Error FileEmpty = new("File.Empty", "No file uploaded");
public static readonly Knot.Shared.Kernel.Error FileInvalidExtension = new("File.InvalidExtension", "Only .zip files are supported");
}

View File

@@ -4,17 +4,17 @@ using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.TelegramImport.Application.Abstractions;
using Knot.Modules.TelegramImport.Application.TelegramImport;
using Knot.Modules.TelegramImport.Infrastructure.Background;
using Knot.Shared.Kernel.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Knot.Modules.TelegramImport.Application.Abstractions;
using Knot.Modules.TelegramImport.Application.TelegramImport;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.TelegramImport.Infrastructure.Background;
namespace Knot.Modules.TelegramImport.Infrastructure.Background;
@@ -34,7 +34,7 @@ public class TelegramImportWorker : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Telegram Import Worker started.");
// В реальном проекте здесь будет чтение из Channels или RabbitMQ
// Для примера оставим заглушку цикла
while (!stoppingToken.IsCancellationRequested)
@@ -55,33 +55,33 @@ public class TelegramImportWorker : BackgroundService
var jobInfo = new ImportJobInfo { JobId = jobId, Status = ImportJobStatus.Processing };
_jobStore.AddOrUpdate(jobInfo);
try
try
{
using var archive = ZipFile.OpenRead(zipPath);
var entries = archive.Entries.Where(e => e.Name.StartsWith("messages") && e.Name.EndsWith(".html")).ToList();
using var archive = ZipFile.OpenRead(zipPath);
var entries = archive.Entries.Where(e => e.Name.StartsWith("messages") && e.Name.EndsWith(".html")).ToList();
// 1. Создание чата (уже было в оригинале, но здесь в фоне)
Guid targetChatId = Guid.NewGuid(); // Упростим логику для демонстрации рефакторинга
// 1. Создание чата (уже было в оригинале, но здесь в фоне)
Guid targetChatId = Guid.NewGuid(); // Упростим логику для демонстрации рефакторинга
foreach (var entry in entries)
{
using var stream = entry.Open();
var messages = await parser.ParseMessagesAsync(stream, "", ct);
foreach (var m in messages)
{
Guid senderGuid = request.Mapping.TryGetValue(m.SenderName ?? "", out var sid) ? sid : request.CurrentUserId;
var textMsg = new TextMessage(Guid.NewGuid(), targetChatId, senderGuid, m.Content, null, null, null, m.CreatedAt, true);
msgRepo.Add(textMsg);
jobInfo.ProcessedMessages++;
_jobStore.AddOrUpdate(jobInfo);
}
await uow.SaveChangesAsync(ct);
}
jobInfo.Status = ImportJobStatus.Completed;
foreach (var entry in entries)
{
using var stream = entry.Open();
var messages = await parser.ParseMessagesAsync(stream, "", ct);
foreach (var m in messages)
{
Guid senderGuid = request.Mapping.TryGetValue(m.SenderName ?? "", out var sid) ? sid : request.CurrentUserId;
var textMsg = new TextMessage(Guid.NewGuid(), targetChatId, senderGuid, m.Content, null, null, null, m.CreatedAt, true);
msgRepo.Add(textMsg);
jobInfo.ProcessedMessages++;
_jobStore.AddOrUpdate(jobInfo);
}
await uow.SaveChangesAsync(ct);
}
jobInfo.Status = ImportJobStatus.Completed;
}
catch (Exception ex)
{

View File

@@ -2,9 +2,14 @@
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.1.2" />
<PackageReference Include="Carter" Version="10.0.0" />
</ItemGroup>
<PropertyGroup>

View File

@@ -7,14 +7,11 @@
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-preview.1.25120.3" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>