Reorganize root folder structure: Remove apps layer layer

This commit is contained in:
Халимов Рустам
2026-03-19 22:22:45 +03:00
parent fb252f9d87
commit 11ebbc853b
296 changed files with 139 additions and 139 deletions

View File

@@ -0,0 +1,14 @@
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace Knot.Modules.Identity.Application.Abstractions;
public interface IIdentityDbContext
{
DbSet<User> Users { get; }
DbSet<Friendship> Friendships { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,10 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Application.Abstractions;
/// <summary>
/// Unit of Work специфичный для модуля Identity.
/// </summary>
public interface IIdentityUnitOfWork : IUnitOfWork
{
}

View File

@@ -0,0 +1,8 @@
using Knot.Modules.Identity.Domain;
namespace Knot.Modules.Identity.Application.Abstractions;
public interface IJwtTokenProvider
{
string Generate(User user);
}

View File

@@ -0,0 +1,36 @@
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);
}
}

View File

@@ -0,0 +1,36 @@
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();
}
}

View File

@@ -0,0 +1,14 @@
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
);

View File

@@ -0,0 +1,11 @@
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
);

View File

@@ -0,0 +1,9 @@
using System;
namespace Knot.Modules.Identity.Application.Friends;
public record FriendshipStatusResponse(
string Status,
Guid? FriendshipId = null,
string? Direction = null
);

View File

@@ -0,0 +1,59 @@
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);
}
}

View File

@@ -0,0 +1,43 @@
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"
));
}
}

View File

@@ -0,0 +1,49 @@
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);
}
}

View File

@@ -0,0 +1,49 @@
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);
}
}

View File

@@ -0,0 +1,36 @@
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();
}
}

View File

@@ -0,0 +1,46 @@
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();
}
}

View File

@@ -0,0 +1,20 @@
using System;
namespace Knot.Modules.Identity.Application.Users.Auth;
public record AuthResponseDto(
string Token,
AuthUserDto User
);
public record AuthUserDto(
Guid Id,
string Username,
string DisplayName,
string? Email,
string? Bio,
string? Avatar,
DateTime? Birthday,
bool IsOnline,
DateTime CreatedAt
);

View File

@@ -0,0 +1,68 @@
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);
}
}

View File

@@ -0,0 +1,43 @@
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);
}
}

View File

@@ -0,0 +1,49 @@
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);
}
}

View File

@@ -0,0 +1,44 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Application.Users.Auth;
namespace Knot.Modules.Identity.Application.Users.GetMe;
public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
public GetMeQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<AuthResponseDto>(IdentityErrors.UserNotFound);
}
var response = new AuthResponseDto(
string.Empty,
new AuthUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Bio,
user.Avatar,
user.Birthday,
true,
user.CreatedAt
)
);
return Result.Success(response);
}
}

View File

@@ -0,0 +1,41 @@
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);
}
}

View File

@@ -0,0 +1,51 @@
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Users.Auth;
using BCrypt.Net;
namespace Knot.Modules.Identity.Application.Users.Login;
/// <summary>
/// Команда для входа пользователя. Возвращает AuthResponseDto.
/// </summary>
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public LoginUserCommandHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(LoginUserCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
{
return Result.Failure<AuthResponseDto>(IdentityErrors.IdentityInvalidCredentials);
}
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
)
));
}
}

View File

@@ -0,0 +1,87 @@
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
)
));
}
}

View File

@@ -0,0 +1,33 @@
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);
}
}

View File

@@ -0,0 +1,43 @@
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);
}
}

View File

@@ -0,0 +1,44 @@
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);
}
}

View File

@@ -0,0 +1,12 @@
using System;
namespace Knot.Modules.Identity.Application.Users;
public record UserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen
);

View File

@@ -0,0 +1,16 @@
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
);

View File

@@ -0,0 +1,40 @@
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;
}
}

View File

@@ -0,0 +1,34 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Domain;
public enum FriendshipStatus
{
Pending,
Accepted,
Declined
}
public sealed class Friendship : Entity<Guid>
{
public Guid UserId { get; private set; }
public Guid FriendId { get; private set; }
public FriendshipStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
private Friendship(Guid id, Guid userId, Guid friendId, FriendshipStatus status) : base(id)
{
UserId = userId;
FriendId = friendId;
Status = status;
CreatedAt = DateTime.UtcNow;
}
public static Friendship Create(Guid userId, Guid friendId)
{
return new Friendship(Guid.NewGuid(), userId, friendId, FriendshipStatus.Pending);
}
public void Accept() => Status = FriendshipStatus.Accepted;
public void Decline() => Status = FriendshipStatus.Declined;
}

View File

@@ -0,0 +1,17 @@
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);
}

View File

@@ -0,0 +1,14 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Domain;
public static class IdentityErrors
{
public static readonly Error FriendsNotFound = new Error("Friends.NotFound", "Friendship not found");
public static readonly Error FriendsSelf = new Error("Friends.Self", "Cannot add yourself");
public static readonly Error FriendsExists = new Error("Friends.Exists", "Friendship already exists");
public static readonly Error UserNotFound = new Error("User.NotFound", "User not found");
public static readonly Error IdentityInvalidCredentials = new Error("Identity.InvalidCredentials", "Неверное имя пользователя или пароль.");
public static readonly Error IdentityRegistrationDisabled = new Error("Identity.RegistrationDisabled", "Registration is disabled by the administrator.");
public static readonly Error IdentityUsernameNotUnique = new Error("Identity.UsernameNotUnique", "Это имя пользователя уже занято.");
}

View File

@@ -0,0 +1,74 @@
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));
}
}

View File

@@ -0,0 +1,21 @@
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;
}
}

View File

@@ -0,0 +1,21 @@
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;
}
}

View File

@@ -0,0 +1,17 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Domain;
public sealed class StoryViewer : Entity<Guid>
{
public Guid StoryId { get; private set; }
public Guid UserId { get; private set; }
public DateTime ViewedAt { get; private set; }
internal StoryViewer(Guid storyId, Guid userId) : base(Guid.NewGuid())
{
StoryId = storyId;
UserId = userId;
ViewedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,60 @@
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;
}
}

View File

@@ -0,0 +1,45 @@
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);
}
}

View File

@@ -0,0 +1,157 @@
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;
}
}

View File

@@ -0,0 +1,63 @@
// <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("20260311180816_InitialIdentity")]
partial class InitialIdentity
{
/// <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.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
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
}
}
}

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialIdentity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "identity");
migrationBuilder.CreateTable(
name: "Users",
schema: "identity",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Username = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: false),
DisplayName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Email = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Users_Username",
schema: "identity",
table: "Users",
column: "Username",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Users",
schema: "identity");
}
}
}

View File

@@ -0,0 +1,136 @@
// <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("20260311200839_UpdateIdentityWithNewFieldsAndTables")]
partial class UpdateIdentityWithNewFieldsAndTables
{
/// <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<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
}
}
}

View File

@@ -0,0 +1,120 @@
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");
}
}
}

View File

@@ -0,0 +1,141 @@
// <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
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddHideStoryViewsToUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "HideStoryViews",
schema: "identity",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "HideStoryViews",
schema: "identity",
table: "Users");
}
}
}

View File

@@ -0,0 +1,178 @@
// <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
}
}
}

View File

@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddHideStoryViewsAndFixStoryViewer : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "StoryViewers",
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),
ViewedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StoryViewers", x => x.Id);
table.ForeignKey(
name: "FK_StoryViewers_Stories_StoryId",
column: x => x.StoryId,
principalSchema: "identity",
principalTable: "Stories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_StoryViewers_StoryId_UserId",
schema: "identity",
table: "StoryViewers",
columns: new[] { "StoryId", "UserId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StoryViewers",
schema: "identity");
}
}
}

View File

@@ -0,0 +1,255 @@
// <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
}
}
}

View File

@@ -0,0 +1,86 @@
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");
}
}
}

View File

@@ -0,0 +1,252 @@
// <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
}
}
}

View File

@@ -0,0 +1,56 @@
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);
}
}

View File

@@ -0,0 +1,42 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
namespace Knot.Modules.Identity.Infrastructure.Services;
public sealed class UserDisplayNameProvider : IUserDisplayNameProvider
{
private readonly IUserRepository _userRepository;
public UserDisplayNameProvider(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<string> GetDisplayNameAsync(Guid userId, CancellationToken ct = default)
{
var user = await _userRepository.GetByIdAsync(userId, ct);
return user?.DisplayName ?? "User";
}
public async Task<UserInfo?> GetUserInfoAsync(Guid userId, CancellationToken ct = default)
{
var user = await _userRepository.GetByIdAsync(userId, ct);
if (user == null)
{
return null;
}
return new UserInfo(user.Id, user.Username, user.DisplayName, user.Avatar);
}
public async Task<IReadOnlyDictionary<Guid, UserInfo>> GetUsersInfoAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
{
var users = await _userRepository.GetByIdsAsync(userIds, ct);
var result = new System.Collections.Generic.Dictionary<Guid, UserInfo>();
foreach (var user in users)
{
result[user.Id] = new UserInfo(user.Id, user.Username, user.DisplayName, user.Avatar);
}
return result;
}
}

View File

@@ -0,0 +1,31 @@
<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>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.4" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,229 @@
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;