47 Commits

Author SHA1 Message Date
Халимов Рустам
a426b63b7d Получение и отправка текста, статусы сообщения 2026-05-15 14:59:54 +03:00
Халимов Рустам
4d4bc8edd1 Разделение навигации и реактивности 2026-05-15 11:00:38 +03:00
Халимов Рустам
40589dbb75 Повторный вход в аккаунт, очистка кэша 2026-05-15 00:10:34 +03:00
Халимов Рустам
0b013def2e Отображение чатов 2026-05-14 22:56:42 +03:00
Халимов Рустам
ccffc5a53c Настройки, авторизация 2026-05-14 12:06:34 +03:00
Халимов Рустам
e8161d23d4 Приложение на флаторе, настройки 2026-05-13 17:21:37 +03:00
Халимов Рустам
89e325556c Удалил к херам нативную версию 2026-05-13 13:49:46 +03:00
Халимов Рустам
df4feeeee5 Правки 2026-05-13 13:48:34 +03:00
Халимов Рустам
00ed4b5959 Фиксы 2026-05-08 23:22:07 +03:00
Халимов Рустам
a0b50b57b0 Вторая часть по кэшу 2026-05-08 22:48:56 +03:00
Халимов Рустам
d4c41b333a compose 2026-05-07 23:34:28 +03:00
Халимов Рустам
9daa786cfb Заготовка 2026-05-07 23:27:37 +03:00
Халимов Рустам
2d2e4b685e Восстановлены голосовые 2026-04-30 10:33:15 +03:00
Халимов Рустам
46c22300e9 Почти плавный скрол чата 2026-04-28 00:16:52 +03:00
Халимов Рустам
d9a20e40b0 Сборка перед кэшированием 2026-04-23 01:31:51 +03:00
Халимов Рустам
945134f029 Нормальные токены 2026-04-19 22:55:38 +03:00
Халимов Рустам
f6400ce3ac Merge branch 'main' into android 2026-04-19 22:31:31 +03:00
Халимов Рустам
d9462069e2 Миграции 2026-04-19 22:22:43 +03:00
Халимов Рустам
9e715fe3ab Токены, правильная работа 2026-04-19 22:13:20 +03:00
Халимов Рустам
8c8ef55b58 Merge branch 'main' into android 2026-04-19 21:24:40 +03:00
Халимов Рустам
70acad56fb Удаление сообщений 2026-04-19 21:05:56 +03:00
Халимов Рустам
629fddfca0 Реакции, но с багом 2026-04-18 00:41:05 +03:00
Халимов Рустам
b68f68a1f2 Анимация, список реакций 2026-04-17 23:51:46 +03:00
Халимов Рустам
754e9e8ad0 Меню для сообщений 2026-04-17 23:44:11 +03:00
Халимов Рустам
ed7521a563 Правка ответов, локализация, поддержка медиа 2026-04-17 22:07:33 +03:00
Халимов Рустам
bef30c2c86 Ответы 2026-04-17 21:59:53 +03:00
Халимов Рустам
8c00d1376d Событие печати, смена адреса, иконки 2026-04-17 16:23:52 +03:00
Халимов Рустам
cea3f4d669 Профиль, редактирование без аватара 2026-04-16 15:41:22 +03:00
Халимов Рустам
8409c51842 Пуши 2026-04-15 02:40:52 +03:00
Халимов Рустам
9560a9235f Сохранение состояния при сворачивании 2026-04-15 02:19:03 +03:00
Халимов Рустам
f17edfc0da Зум и пролистывание 2026-04-15 02:12:43 +03:00
Халимов Рустам
c6bebec599 Кнопка вниз 2026-04-15 02:01:46 +03:00
Халимов Рустам
5a71e5bbfa Подгрузка старых сообщений 2026-04-15 01:55:23 +03:00
Халимов Рустам
5b0133d55e Прокрутка чата 2026-04-15 01:35:22 +03:00
Халимов Рустам
dcb733ac01 Нормальный чат 2026-04-15 01:30:48 +03:00
Халимов Рустам
487cb1b12b Рабочие счетчики и переработка чата 2026-04-15 01:14:55 +03:00
Халимов Рустам
58fdf1aca1 Чат, вложения 2026-04-14 21:53:44 +03:00
Халимов Рустам
118f8b8971 Прочтение 2026-04-14 15:09:44 +03:00
Халимов Рустам
8165b74e43 Отправка гиф 2026-04-14 13:55:01 +03:00
Халимов Рустам
8ce4bc714f Гиф 2026-04-14 13:46:17 +03:00
Халимов Рустам
2d0bc0d75c Чат, правки 2026-04-14 12:52:03 +03:00
Халимов Рустам
d7e75797ef Ответы, аудио, голосовые 2026-04-14 12:29:32 +03:00
Халимов Рустам
58bbdae26c Настройки 2026-04-14 11:46:55 +03:00
Халимов Рустам
3310a3c4a4 Правка чата 2026-04-14 11:31:20 +03:00
Халимов Рустам
d8b0d86534 Чат 2026-04-14 10:48:07 +03:00
Халимов Рустам
dc051fa9ae Авторизация, нерабочий чат 2026-04-14 01:41:51 +03:00
Халимов Рустам
1fb1be47dd Приложение 2026-04-14 01:15:54 +03:00
204 changed files with 31795 additions and 2156 deletions

10
.gitignore vendored
View File

@@ -87,3 +87,13 @@ postgres_data/
tmp/ tmp/
*.txt *.txt
# Android / Kotlin Mobile
client-mobile/.gradle/
client-mobile/.idea/
client-mobile/.run/
client-mobile/build/
client-mobile/.cxx/
client-mobile/local.properties
client-mobile/*.iml
client-mobile/.kotlin/

View File

@@ -7,9 +7,8 @@ using FluentAssertions;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Chats.Create; using Knot.Modules.Conversations.Application.Chats.Create;
namespace Knot.Modules.Conversations.UnitTests.Chats; namespace Knot.Modules.Conversations.UnitTests.Chats;

View File

@@ -7,9 +7,10 @@ using FluentAssertions;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Chats.GetChats; using Knot.Modules.Conversations.Application.Chats.GetChats;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;

View File

@@ -1,9 +1,8 @@
using FluentAssertions; using FluentAssertions;
using NSubstitute; using NSubstitute;
using Knot.Modules.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites; using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
using Knot.Modules.Conversations.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Xunit; using Xunit;

View File

@@ -23,10 +23,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Chats\Knot.Modules.Conversations.csproj" /> <ProjectReference Include="..\..\..\src\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" /> <ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" /> <ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" /> <ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -5,15 +5,17 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FluentAssertions; using FluentAssertions;
using MediatR; using MediatR;
using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Messages.Send; using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Settings.Application.Settings.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Modules.Settings.Application.Settings.DTOs; using Knot.Contracts.Settings.Application.DTOs;
namespace Knot.Modules.Conversations.UnitTests.Messages; namespace Knot.Modules.Conversations.UnitTests.Messages;
@@ -24,6 +26,8 @@ public class SendMessageCommandHandlerTests
private readonly IChatsUnitOfWork _unitOfWork; private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly IMessagesSettings _messagesSettings; private readonly IMessagesSettings _messagesSettings;
private readonly IIdempotencyStore _idempotencyStore;
private readonly ILogger<SendMessageCommandHandler> _logger;
private readonly SendMessageCommandHandler _handler; private readonly SendMessageCommandHandler _handler;
public SendMessageCommandHandlerTests() public SendMessageCommandHandlerTests()
@@ -33,11 +37,13 @@ public class SendMessageCommandHandlerTests
_unitOfWork = Substitute.For<IChatsUnitOfWork>(); _unitOfWork = Substitute.For<IChatsUnitOfWork>();
_mediator = Substitute.For<IMediator>(); _mediator = Substitute.For<IMediator>();
_messagesSettings = Substitute.For<IMessagesSettings>(); _messagesSettings = Substitute.For<IMessagesSettings>();
_idempotencyStore = Substitute.For<IIdempotencyStore>();
_logger = Substitute.For<ILogger<SendMessageCommandHandler>>();
var config = new Knot.Modules.Settings.Application.Settings.DTOs.MessagesConfig(); var config = new MessagesConfig();
_messagesSettings.Current.Returns(config); _messagesSettings.Current.Returns(config);
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings); _handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings, _idempotencyStore, _logger);
} }
[Fact] [Fact]

View File

@@ -1,13 +1,13 @@
using FluentAssertions; using FluentAssertions;
using Knot.Modules.Profiles.Application.Profiles.UpdateProfile; using Knot.Modules.Profiles.Application.Profiles.UpdateProfile;
using Knot.Modules.Profiles.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Profiles.Domain; using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Contracts.Profiles.Application.DTOs;
namespace Knot.Modules.Profiles.UnitTests; namespace Knot.Modules.Profiles.UnitTests;
@@ -25,11 +25,14 @@ public class UpdateProfileCommandHandlerTests
[Fact] [Fact]
public async Task Handle_ShouldReturnError_WhenProfileNotFound() public async Task Handle_ShouldReturnError_WhenProfileNotFound()
{ {
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null, null); // Arrange
_profileRepository.GetAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((UserProfileDto?)null); var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null);
_profileRepository.GetByIdAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((ProfileDocument?)null);
// Act
var result = await _handler.Handle(command, CancellationToken.None); var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue(); result.IsFailure.Should().BeTrue();
} }
} }

View File

@@ -4,6 +4,7 @@ public interface IJwtTokenProvider
{ {
string GenerateAccessToken(Guid userId, string username); string GenerateAccessToken(Guid userId, string username);
string GenerateRefreshToken(); string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
string Generate(Guid userId, string username, string displayName, string? avatar); string Generate(Guid userId, string username, string displayName, string? avatar);
string Generate(Domain.UserContract user); string Generate(Domain.UserContract user);
} }

View File

@@ -7,8 +7,7 @@ public static class AuthErrors
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials"); public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled"); public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken"); public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
public static Error IdentityRegistrationFailed => new("Auth.RegistrationFailed", "Failed to register user");
public static Error RefreshTokenExpired => new("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again.");
public static Error UserNotFound => new("Auth.UserNotFound", "User not found"); public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
public static Error PasswordConfirmationMismatch => new("Auth.PasswordConfirmationMismatch", "Passwords do not match");
public static Error PasswordTooShort => new("Auth.PasswordTooShort", "Password must be at least 8 characters");
public static Error OldPasswordInvalid => new("Auth.OldPasswordInvalid", "Current password is incorrect");
} }

View File

@@ -19,4 +19,12 @@ public class UserContract
public bool IsExternal { get; set; } public bool IsExternal { get; set; }
public string? Domain { get; set; } public string? Domain { get; set; }
public DateTime? LastSeen { get; set; } public DateTime? LastSeen { get; set; }
public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiry { get; set; }
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
{
RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
}
} }

View File

@@ -0,0 +1,17 @@
namespace Knot.Contracts.Conversations.Application.Abstractions;
/// <summary>
/// Хранилище для обеспечения идемпотентности операций.
/// Если ключ уже существует — возвращает сохранённый результат без повторного выполнения.
/// </summary>
public interface IIdempotencyStore
{
/// <summary>
/// Возвращает сохранённый результат по ключу или выполняет factory, сохраняет и возвращает результат.
/// </summary>
Task<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
TimeSpan? expiration = null,
CancellationToken cancellationToken = default);
}

View File

@@ -9,8 +9,4 @@ public static class ProfilesErrors
public static Error AvatarNotFound => new("Profiles.AvatarNotFound", "Avatar not found"); public static Error AvatarNotFound => new("Profiles.AvatarNotFound", "Avatar not found");
public static Error InvalidAvatarFormat => new("Profiles.InvalidAvatarFormat", "Invalid avatar format"); public static Error InvalidAvatarFormat => new("Profiles.InvalidAvatarFormat", "Invalid avatar format");
public static Error AvatarUploadFailed => new("Profiles.AvatarUploadFailed", "Avatar upload failed"); public static Error AvatarUploadFailed => new("Profiles.AvatarUploadFailed", "Avatar upload failed");
public static Error BioTooLong => new("Profiles.BioTooLong", "Bio must be 200 characters or less");
public static Error StatusTextTooLong => new("Profiles.StatusTextTooLong", "Status text must be 50 characters or less");
public static Error StatusEmpty => new("Statuses.Empty", "Status emoji or text is required");
public static Error InvalidPreset => new("Statuses.InvalidPreset", "Unknown status preset");
} }

View File

@@ -1,9 +0,0 @@
namespace Knot.Contracts.Profiles.Application.DTOs;
public sealed class StatusPresetDto
{
public string Id { get; set; } = "";
public string Emoji { get; set; } = "";
public string TextRu { get; set; } = "";
public string TextEn { get; set; } = "";
}

View File

@@ -12,13 +12,5 @@ public class UserProfileDto
public DateTime? LastSeen { get; set; } public DateTime? LastSeen { get; set; }
public DateTime? Birthday { get; set; } public DateTime? Birthday { get; set; }
public bool IsPremium { get; set; } public bool IsPremium { get; set; }
public UserStatusDto? Status { get; set; }
public Guid? CurrentStatusId { get; set; }
public string? StatusText { get; set; }
public string? StatusEmoji { get; set; }
public DateTime? StatusExpiresAt { get; set; }
public bool IsInvisible { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
} }

View File

@@ -1,11 +0,0 @@
namespace Knot.Contracts.Profiles.Application.DTOs;
public sealed class UserStatusDto
{
public Guid Id { get; set; }
public string Type { get; set; } = "Custom";
public string Emoji { get; set; } = "";
public string Text { get; set; } = "";
public DateTime CreatedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
}

View File

@@ -1,11 +0,0 @@
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Shared.Kernel;
namespace Knot.Contracts.Profiles.Domain;
public interface IProfileStatusWriter
{
Task<Result<UserProfileDto>> SetCustomAsync(Guid userId, string emoji, string text, DateTime? expiresAt, string? presetKey, CancellationToken cancellationToken = default);
Task<Result<UserProfileDto>> ClearAsync(Guid userId, CancellationToken cancellationToken = default);
}

View File

@@ -1,14 +0,0 @@
using Knot.Contracts.Profiles.Application.DTOs;
namespace Knot.Contracts.Profiles.Domain;
public interface IUserStatusRepository
{
Task<UserStatusDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyDictionary<Guid, UserStatusDto>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default);
Task InsertAsync(UserStatusDto dto, Guid userId, CancellationToken cancellationToken = default);
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -32,11 +32,11 @@ using Knot.Modules.TelegramImport.Presentation.Endpoints;
using Knot.Modules.WebRtc; using Knot.Modules.WebRtc;
using Knot.Modules.WebRtc.Presentation.Endpoints; using Knot.Modules.WebRtc.Presentation.Endpoints;
using Knot.Shared.Infrastructure; using Knot.Shared.Infrastructure;
using MediatR;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using MediatR;
@@ -250,7 +250,6 @@ app.MapStoriesEndpoints();
app.MapContactsEndpoints(); app.MapContactsEndpoints();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapProfilesEndpoints(); app.MapProfilesEndpoints();
app.MapStatusesEndpoints();
app.MapFederationEndpoints(); app.MapFederationEndpoints();
app.MapKlipyEndpoints(); app.MapKlipyEndpoints();
app.MapChatsEndpoints(); app.MapChatsEndpoints();

View File

@@ -13,7 +13,8 @@
"Secret": "knot_super_secret_key_1234567890_knot", "Secret": "knot_super_secret_key_1234567890_knot",
"Issuer": "Knot", "Issuer": "Knot",
"Audience": "KnotUsers", "Audience": "KnotUsers",
"ExpiryInMinutes": 1440 "ExpiryInMinutes": 1440,
"RefreshExpiryInDays": 30
}, },
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot" "KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
} }

View File

@@ -5,5 +5,8 @@ namespace Knot.Modules.Auth.Application.Abstractions;
public interface IJwtTokenProvider public interface IJwtTokenProvider
{ {
string Generate(User user); string Generate(User user);
string Generate(Guid userId, string username, string displayName, string? avatar);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
} }

View File

@@ -1,48 +0,0 @@
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Auth.Application.Users.ChangePassword;
public sealed record ChangePasswordCommand(
Guid UserId,
string OldPassword,
string NewPassword,
string ConfirmPassword) : ICommand;
internal sealed class ChangePasswordCommandHandler : ICommandHandler<ChangePasswordCommand>
{
private readonly IAuthDbContext _dbContext;
public ChangePasswordCommandHandler(IAuthDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Result> Handle(ChangePasswordCommand request, CancellationToken cancellationToken)
{
if (request.NewPassword != request.ConfirmPassword)
return Result.Failure(AuthErrors.PasswordConfirmationMismatch);
if (request.NewPassword.Length < 8)
return Result.Failure(AuthErrors.PasswordTooShort);
var user = await _dbContext.Set<User>()
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user is null)
return Result.Failure(AuthErrors.UserNotFound);
if (!BCrypt.Net.BCrypt.Verify(request.OldPassword, user.PasswordHash))
return Result.Failure(AuthErrors.OldPasswordInvalid);
user.ChangePassword(BCrypt.Net.BCrypt.HashPassword(request.NewPassword));
user.SetRefreshToken(null);
await _dbContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -1,4 +1,6 @@
using Knot.Contracts.Auth.Application.Auth.DTOs; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs; using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
@@ -9,10 +11,12 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto> internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public GetMeQueryHandler(IUserRepository userRepository) public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_tokenProvider = tokenProvider;
} }
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken) public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
@@ -23,9 +27,18 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound); return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
} }
// Check if access token needs to be refreshed (less than 1 hour remaining)
string? newAccessToken = null;
// We can't directly check the current token's expiry here, but we can
// always issue a new token if the user is authenticated
// For now, let's issue a new token on every request (simplified approach)
// A better approach would be to parse the incoming token and check expiry
newAccessToken = _tokenProvider.Generate(user);
var response = new AuthResponseDto var response = new AuthResponseDto
{ {
AccessToken = string.Empty, AccessToken = newAccessToken,
RefreshToken = string.Empty, RefreshToken = string.Empty,
UserId = user.Id, UserId = user.Id,
Username = user.Username, Username = user.Username,

View File

@@ -5,9 +5,6 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Login; namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> AuthResponseDto.
/// </summary>
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>; public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto> public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
@@ -31,14 +28,21 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
} }
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar); string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
// Save refresh token to database
user.SetRefreshToken(refreshToken, refreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto return Result.Success(new AuthResponseDto
{ {
AccessToken = token, AccessToken = token,
RefreshToken = string.Empty, RefreshToken = refreshToken,
UserId = user.Id, UserId = user.Id,
Username = user.Username, Username = user.Username,
DisplayName = user.DisplayName DisplayName = user.DisplayName
}); });
} }
} }

View File

@@ -0,0 +1,7 @@
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;

View File

@@ -0,0 +1,65 @@
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public RefreshTokenCommandHandler(
IUserRepository userRepository,
IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
}
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
if (user == null)
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
}
// Check if refresh token has expired
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
{
// Clear expired refresh token
user.SetRefreshToken(null, null);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Failure<AuthResponseDto>(
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
}
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName,
Avatar = user.Avatar
});
}
}

View File

@@ -1,5 +1,4 @@
using BCrypt.Net; using BCrypt.Net;
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions; using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs; using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
@@ -9,9 +8,6 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Register; namespace Knot.Modules.Auth.Application.Users.Register;
/// <summary>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
/// </summary>
public sealed record RegisterUserCommand( public sealed record RegisterUserCommand(
string Username, string Username,
string Password, string Password,
@@ -19,9 +15,6 @@ public sealed record RegisterUserCommand(
string? Email, string? Email,
string? Bio) : ICommand<AuthResponseDto>; string? Bio) : ICommand<AuthResponseDto>;
/// <summary>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
/// </summary>
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto> internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
@@ -48,16 +41,13 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled); return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
} }
// 1. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken)) if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{ {
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique); return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
} }
// 2. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
var user = User.Create( var user = User.Create(
request.Username, request.Username,
passwordHash, passwordHash,
@@ -65,21 +55,33 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
request.Email, request.Email,
request.Bio); request.Bio);
// 4. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> - <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20> Domain User var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository; repoImpl?.Add(user);
repoWithDomainUserAdd?.Add(user);
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar); // Get the saved user as contract
var userContract = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
if (userContract == null)
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationFailed);
}
string token = _tokenProvider.Generate(userContract.Id, userContract.Username, userContract.DisplayName, userContract.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
userContract.SetRefreshToken(refreshToken, refreshExpiry);
await _userRepository.UpdateAsync(userContract, cancellationToken);
return Result.Success(new AuthResponseDto return Result.Success(new AuthResponseDto
{ {
AccessToken = token, AccessToken = token,
RefreshToken = string.Empty, RefreshToken = refreshToken,
UserId = user.Id, UserId = userContract.Id,
Username = user.Username, Username = userContract.Username,
DisplayName = user.DisplayName DisplayName = userContract.DisplayName
}); });
} }
} }

View File

@@ -26,6 +26,7 @@ public sealed class User : AggregateRoot<Guid>
public bool IsBanned { get; private set; } public bool IsBanned { get; private set; }
public string? PhoneNumber { get; private set; } public string? PhoneNumber { get; private set; }
public string? RefreshToken { get; private set; } public string? RefreshToken { get; private set; }
public DateTime? RefreshTokenExpiry { get; private set; }
public DateTime? BannedUntil { get; private set; } public DateTime? BannedUntil { get; private set; }
public void Ban() { public void Ban() {
@@ -48,9 +49,10 @@ public sealed class User : AggregateRoot<Guid>
PhoneNumber = phoneNumber; PhoneNumber = phoneNumber;
} }
public void SetRefreshToken(string? refreshToken) public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
{ {
RefreshToken = refreshToken; RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
} }
public void SetBannedUntil(DateTime? bannedUntil) public void SetBannedUntil(DateTime? bannedUntil)
@@ -79,6 +81,8 @@ public sealed class User : AggregateRoot<Guid>
BannedUntil = contract.BannedUntil; BannedUntil = contract.BannedUntil;
SetOnline(contract.IsOnline, contract.LastSeen); SetOnline(contract.IsOnline, contract.LastSeen);
UserDomain = contract.Domain; UserDomain = contract.Domain;
RefreshToken = contract.RefreshToken;
RefreshTokenExpiry = contract.RefreshTokenExpiry;
} }
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null) private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
@@ -181,7 +185,9 @@ public sealed class User : AggregateRoot<Guid>
IsOnline = IsOnline, IsOnline = IsOnline,
IsExternal = IsExternal, IsExternal = IsExternal,
Domain = _domain, Domain = _domain,
LastSeen = LastSeen LastSeen = LastSeen,
RefreshToken = RefreshToken,
RefreshTokenExpiry = RefreshTokenExpiry
}; };
} }
} }

View File

@@ -50,6 +50,12 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
return Convert.ToBase64String(randomBytes); return Convert.ToBase64String(randomBytes);
} }
public DateTime GetRefreshTokenExpiry()
{
var expiryInDays = int.Parse(_configuration["Jwt:RefreshExpiryInDays"] ?? "30");
return DateTime.UtcNow.AddDays(expiryInDays);
}
public string Generate(Guid userId, string username, string displayName, string? avatar) public string Generate(Guid userId, string username, string displayName, string? avatar)
{ {
var claims = new Claim[] var claims = new Claim[]

View File

@@ -0,0 +1,100 @@
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AuthDbContext))]
[Migration("20270419220000_AddRefreshTokenExpiry")]
partial class AddRefreshTokenExpiry
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
.HasAnnotation("Relational:DefaultSchema", "identity");
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("timestamp with time zone");
b.Property<string>("Bio")
.HasColumnType("text");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<DateTime?>("RefreshTokenExpiry")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class AddRefreshTokenExpiry : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users",
type: "timestamp with time zone",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users");
}
}
}

View File

@@ -1,20 +1,18 @@
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Modules.Auth.Application.Users.GetMe; using Knot.Modules.Auth.Application.Users.GetMe;
using Knot.Modules.Auth.Application.Users.ChangePassword; using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.RefreshToken;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Shared.Kernel;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace Knot.Modules.Auth.Presentation.Endpoints; namespace Knot.Modules.Auth.Presentation.Endpoints;
public static class AuthEndpoints public static class AuthEndpoints
{ {
public sealed record ChangePasswordRequest(string OldPassword, string NewPassword, string ConfirmPassword);
public static void MapAuthEndpoints(this WebApplication app) public static void MapAuthEndpoints(this WebApplication app)
{ {
var group = app.MapGroup("api/auth"); var group = app.MapGroup("api/auth");
@@ -31,24 +29,16 @@ public static class AuthEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized(); return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
}); });
group.MapPost("refresh", async ([FromBody] RefreshTokenCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
});
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) => group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{ {
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct); var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
}).RequireAuthorization(); }).RequireAuthorization();
group.MapPost("change-password", async ([FromBody] ChangePasswordRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(
new ChangePasswordCommand(userContext.UserId, request.OldPassword, request.NewPassword, request.ConfirmPassword),
ct);
if (result.IsSuccess) return Results.Ok();
if (result.Error.Code == "Auth.OldPasswordInvalid")
return Results.StatusCode(StatusCodes.Status403Forbidden);
return Results.BadRequest(new { error = result.Error.Code ?? result.Error.Description });
}).RequireAuthorization();
} }
} }

View File

@@ -32,7 +32,8 @@ public record MessageDetailDto(
bool? PollIsMultipleChoice = null, bool? PollIsMultipleChoice = null,
bool? PollIsAnonymous = null, bool? PollIsAnonymous = null,
bool? PollIsClosed = null, bool? PollIsClosed = null,
List<Guid>? UserVotedOptionIds = null List<Guid>? UserVotedOptionIds = null,
bool IsDeletedForUser = false
); );
public record ReplyToMessageDto( public record ReplyToMessageDto(

View File

@@ -42,10 +42,16 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
if (request.DeleteForAll) if (request.DeleteForAll)
{ {
// Only message sender can delete for everyone
if (message.SenderId == request.UserId) if (message.SenderId == request.UserId)
{ {
message.Delete(); message.Delete();
} }
else
{
// If not the sender, just delete for current user
message.DeleteForUser(request.UserId);
}
} }
else else
{ {
@@ -55,24 +61,13 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
await _messageRepository.UpdateAsync(message, cancellationToken); await _messageRepository.UpdateAsync(message, cancellationToken);
} }
if (request.DeleteForAll) // Notify all clients in the chat about the deletion
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
{ {
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new chatId = request.ChatId,
{ messageIds = request.MessageIds,
chatId = request.ChatId, deleteForAll = request.DeleteForAll
messageIds = request.MessageIds, });
deleteForAll = true
});
}
else
{
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = false
});
}
return global::Knot.Shared.Kernel.Result.Success(); return global::Knot.Shared.Kernel.Result.Success();
} }

View File

@@ -3,11 +3,11 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; 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.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using MediatR; using MediatR;
@@ -114,7 +114,8 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
senders.TryGetValue(message.SenderId, out var sender); senders.TryGetValue(message.SenderId, out var sender);
reactionsByMessage.TryGetValue(message.Id, out var reactions); reactionsByMessage.TryGetValue(message.Id, out var reactions);
Message? replyMsg = null; Message? replyMsg = null;
if (message.ReplyToId.HasValue) if (message.ReplyToId.HasValue)
{ {
@@ -141,7 +142,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(), replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
) : null, ) : null,
message is TextMessage tm ? tm.Quote : null, (message as TextMessage)?.Quote,
message.IsEdited, message.IsEdited,
message.IsDeleted, message.IsDeleted,
message.CreatedAt, message.CreatedAt,
@@ -154,19 +155,22 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(), (message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
reactions?.Select(r => { reactions?.Select(r =>
{
senders.TryGetValue(r.UserId, out var ru); senders.TryGetValue(r.UserId, out var ru);
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null); return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
}).ToList() ?? new List<MessageReactionDto>(), }).ToList() ?? new List<MessageReactionDto>(),
(message as CallMessage)?.CallType, (message as CallMessage)?.CallType,
(message as CallMessage)?.CallStatus, (message as CallMessage)?.CallStatus,
(message as CallMessage)?.Duration, (message as CallMessage)?.Duration,
(message as PollMessage)?.Options.Select(o => { (message as PollMessage)?.Options.Select(o =>
{
var pm = (PollMessage)message; var pm = (PollMessage)message;
var voters = pm.IsAnonymous == false var voters = pm.IsAnonymous == false
? pm.Votes ? pm.Votes
.Where(v => v.OptionId == o.Id) .Where(v => v.OptionId == o.Id)
.Select(v => { .Select(v =>
{
senders.TryGetValue(v.UserId, out var vu); senders.TryGetValue(v.UserId, out var vu);
return vu != null return vu != null
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar) ? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
@@ -174,12 +178,13 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
}) })
.ToList() .ToList()
: null; : null;
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null); return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
}).ToList(), }).ToList(),
(message as PollMessage)?.IsMultipleChoice, (message as PollMessage)?.IsMultipleChoice,
(message as PollMessage)?.IsAnonymous, (message as PollMessage)?.IsAnonymous,
(message as PollMessage)?.IsClosed, (message as PollMessage)?.IsClosed,
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList() (message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
message.IsDeletedForUser(request.UserId)
)); ));
} }

View File

@@ -1,9 +1,10 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Microsoft.Extensions.Logging;
namespace Knot.Modules.Conversations.Application.Messages.Send; namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -30,7 +31,8 @@ public sealed record SendMessageCommand(
DateTime? PollExpiresAt = null, DateTime? PollExpiresAt = null,
string? CallType = null, string? CallType = null,
string? CallStatus = null, string? CallStatus = null,
int? Duration = null) : ICommand<Guid>; int? Duration = null,
string? IdempotencyKey = null) : ICommand<Guid>;
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid> public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
{ {
@@ -39,64 +41,74 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
private readonly IChatsUnitOfWork _unitOfWork; private readonly IChatsUnitOfWork _unitOfWork;
private readonly MediatR.IMediator _mediator; private readonly MediatR.IMediator _mediator;
private readonly IMessagesSettings _messagesSettings; private readonly IMessagesSettings _messagesSettings;
private readonly IIdempotencyStore _idempotencyStore;
private readonly ILogger<SendMessageCommandHandler> _logger;
public SendMessageCommandHandler( public SendMessageCommandHandler(
IChatRepository chatRepository, IChatRepository chatRepository,
IMessageRepository messageRepository, IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork, IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator, MediatR.IMediator mediator,
IMessagesSettings messagesSettings) IMessagesSettings messagesSettings,
IIdempotencyStore idempotencyStore,
ILogger<SendMessageCommandHandler> logger)
{ {
_chatRepository = chatRepository; _chatRepository = chatRepository;
_messageRepository = messageRepository; _messageRepository = messageRepository;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mediator = mediator; _mediator = mediator;
_messagesSettings = messagesSettings; _messagesSettings = messagesSettings;
_idempotencyStore = idempotencyStore;
_logger = logger;
} }
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken) public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{ {
// 1. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
var key = $"send_msg:{request.ChatId}:{request.IdempotencyKey}";
return await _idempotencyStore.GetOrCreateAsync(
key,
factory: ct => ExecuteAsync(request, ct),
cancellationToken: cancellationToken);
}
return await ExecuteAsync(request, cancellationToken);
}
private async Task<Result<Guid>> ExecuteAsync(SendMessageCommand request, CancellationToken cancellationToken)
{
// 1. Проверка существования чата
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken); var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null) if (chat is null)
{ {
return Result.Failure<Guid>(ChatErrors.ChatsNotFound); return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
} }
// 2. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>, <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> // 2. Проверка, является ли отправитель участником чата
if (!chat.Members.Any(m => m.UserId == request.SenderId)) if (!chat.Members.Any(m => m.UserId == request.SenderId))
{ {
return Result.Failure<Guid>(ChatErrors.ChatsForbidden); return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
} }
// 3. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> // 3. Создание сообщения
Message message; Message message;
if (request.Type == "story_reply" || request.Type == "story_reaction") if (request.Type == "story_reply" || request.Type == "story_reaction")
{ {
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled); if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image; var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
message = new StoryMessage( message = new StoryMessage(
Guid.NewGuid(), Guid.NewGuid(),
request.ChatId, request.ChatId,
request.SenderId, request.SenderId,
request.StoryId ?? Guid.Empty, request.StoryId ?? Guid.Empty,
request.StoryMediaUrl ?? string.Empty, request.StoryMediaUrl ?? string.Empty,
request.StoryMediaType, request.StoryMediaType,
request.Content, request.Content,
request.ReplyToId, request.ReplyToId,
request.ForwardedFromId, request.ForwardedFromId,
DateTime.UtcNow, DateTime.UtcNow,
false); false);
} }
else if (request.Attachments != null && request.Attachments.Any()) else if (request.Attachments != null && request.Attachments.Any())
@@ -106,26 +118,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
var firstAtt = request.Attachments.First(); var firstAtt = request.Attachments.First();
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File; var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
message = new MediaMessage( message = new MediaMessage(
Guid.NewGuid(), Guid.NewGuid(),
request.ChatId, request.ChatId,
request.SenderId, request.SenderId,
parsedType, parsedType,
request.Content, request.Content,
request.ReplyToId, request.ReplyToId,
request.ForwardedFromId, request.ForwardedFromId,
DateTime.UtcNow, DateTime.UtcNow,
false); false);
foreach (var att in request.Attachments) foreach (var att in request.Attachments)
{ {
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File; var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
@@ -167,25 +170,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
{ {
message = new TextMessage( message = new TextMessage(
Guid.NewGuid(), Guid.NewGuid(),
request.ChatId, request.ChatId,
request.SenderId, request.SenderId,
request.Content ?? string.Empty, request.Content ?? string.Empty,
request.ReplyToId, request.ReplyToId,
request.Quote, request.Quote,
request.ForwardedFromId, request.ForwardedFromId,
DateTime.UtcNow, DateTime.UtcNow,
false); false);
} }
// 4. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> High-Water Mark // 4. Обновление High-Water Mark
chat.IncrementSequenceId(); chat.IncrementSequenceId();
message.SetSequenceId(chat.LastMessageSequenceId); message.SetSequenceId(chat.LastMessageSequenceId);
@@ -193,19 +188,15 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
senderMember.UpdateReadCursor(message.Id, message.SequenceId); senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id); senderMember.UpdateDeliveredCursor(message.Id);
// 5. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> // 5. Сохранение
_messageRepository.Add(message); _messageRepository.Add(message);
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
await _mediator.Publish(new MessageSentDomainEvent( await _mediator.Publish(new MessageSentDomainEvent(
message.Id, message.Id,
message.ChatId, message.ChatId,
message.SenderId, message.SenderId,
message.Content), message.Content),
cancellationToken); cancellationToken);
return Result.Success(message.Id); return Result.Success(message.Id);

View File

@@ -1,6 +1,8 @@
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Conversations.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.Idempotency;
using Knot.Modules.Conversations.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo; using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
using Knot.Modules.Conversations.Infrastructure.Services; using Knot.Modules.Conversations.Infrastructure.Services;
@@ -8,7 +10,6 @@ using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Knot.Contracts.Messaging.Application.Abstractions;
namespace Knot.Modules.Conversations; namespace Knot.Modules.Conversations;
@@ -42,7 +43,10 @@ public static class DependencyInjection
services.AddScoped<Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService, UserStatusService>(); services.AddScoped<Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService, UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>(); services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>(); services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
services.AddMemoryCache();
services.AddSingleton<Knot.Contracts.Conversations.Application.Abstractions.IIdempotencyStore, MemoryCacheIdempotencyStore>();
return services; return services;
} }
} }

View File

@@ -0,0 +1,54 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Microsoft.Extensions.Caching.Memory;
namespace Knot.Modules.Conversations.Infrastructure.Idempotency;
/// <summary>
/// Реализация хранилища идемпотентности на основе IMemoryCache.
/// Использует семафор для предотвращения race condition при одновременных запросах с одинаковым ключом.
/// </summary>
public sealed class MemoryCacheIdempotencyStore : IIdempotencyStore
{
private readonly IMemoryCache _cache;
private readonly SemaphoreSlim _semaphore = new(1, 1);
public MemoryCacheIdempotencyStore(IMemoryCache cache)
{
_cache = cache;
}
public async Task<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
TimeSpan? expiration = null,
CancellationToken cancellationToken = default)
{
if (_cache.TryGetValue(key, out T? cachedValue) && cachedValue is not null)
{
return cachedValue;
}
await _semaphore.WaitAsync(cancellationToken);
try
{
// Double-check после получения блокировки
if (_cache.TryGetValue(key, out cachedValue) && cachedValue is not null)
{
return cachedValue;
}
var value = await factory(cancellationToken);
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(expiration ?? TimeSpan.FromHours(24))
.SetPriority(CacheItemPriority.Normal);
_cache.Set(key, value, options);
return value;
}
finally
{
_semaphore.Release();
}
}
}

View File

@@ -21,7 +21,6 @@ using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Profiles.Domain;
namespace Knot.Modules.Conversations.Infrastructure.SignalR; namespace Knot.Modules.Conversations.Infrastructure.SignalR;
@@ -52,7 +51,6 @@ public sealed class ChatHub : Hub
private readonly ILogger<ChatHub> _logger; private readonly ILogger<ChatHub> _logger;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly IUserDisplayNameProvider _userProvider; private readonly IUserDisplayNameProvider _userProvider;
private readonly IProfileRepository _profileRepository;
public ChatHub( public ChatHub(
ISender sender, ISender sender,
@@ -62,8 +60,7 @@ public sealed class ChatHub : Hub
IMessageRepository messageRepository, IMessageRepository messageRepository,
ILogger<ChatHub> logger, ILogger<ChatHub> logger,
IMemoryCache cache, IMemoryCache cache,
IUserDisplayNameProvider userProvider, IUserDisplayNameProvider userProvider)
IProfileRepository profileRepository)
{ {
_sender = sender; _sender = sender;
_userContext = userContext; _userContext = userContext;
@@ -73,7 +70,6 @@ public sealed class ChatHub : Hub
_logger = logger; _logger = logger;
_cache = cache; _cache = cache;
_userProvider = userProvider; _userProvider = userProvider;
_profileRepository = profileRepository;
} }
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
@@ -99,15 +95,7 @@ public sealed class ChatHub : Hub
userId, Context.ConnectionId, userChats.Count); userId, Context.ConnectionId, userChats.Count);
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, Context.ConnectionAborted); await Clients.Others.SendAsync("user_online", new { userId });
if (!isInvisible)
{
await BroadcastPresenceToVisibleUsersAsync(
"user_online",
new { userId },
_userContext.UserId,
Context.ConnectionAborted);
}
} }
await base.OnConnectedAsync(); await base.OnConnectedAsync();
} }
@@ -123,22 +111,7 @@ public sealed class ChatHub : Hub
if (set.Count == 0) if (set.Count == 0)
{ {
_userConnections.TryRemove(userId, out _); _userConnections.TryRemove(userId, out _);
try await Clients.Others.SendAsync("user_offline", new { userId, lastSeen = DateTime.UtcNow });
{
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, CancellationToken.None);
if (!isInvisible)
{
await BroadcastPresenceToVisibleUsersAsync(
"user_offline",
new { userId, lastSeen = DateTime.UtcNow },
_userContext.UserId,
CancellationToken.None);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Presence broadcast on disconnect failed for {UserId}", userId);
}
} }
} }
_cache.Set("Global_OnlineUsersCount", _userConnections.Count); _cache.Set("Global_OnlineUsersCount", _userConnections.Count);
@@ -147,56 +120,6 @@ public sealed class ChatHub : Hub
await base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
} }
private async Task<bool> IsUserInvisibleAsync(Guid userId, CancellationToken ct)
{
var profile = await _profileRepository.GetAsync(userId, ct);
return profile?.IsInvisible ?? false;
}
private async Task BroadcastPresenceToVisibleUsersAsync(
string eventName,
object payload,
Guid sourceUserId,
CancellationToken cancellationToken)
{
var recipients = _userConnections.Keys
.Where(id => id != sourceUserId.ToString())
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
if (recipients.Count == 0)
return;
var recipientProfiles = await _profileRepository.GetAsync(recipients, cancellationToken);
var invisibleRecipientIds = recipientProfiles
.Where(p => p.IsInvisible)
.Select(p => p.UserId)
.ToHashSet();
foreach (var kvp in _userConnections)
{
if (!Guid.TryParse(kvp.Key, out var recipientId))
continue;
if (recipientId == sourceUserId)
continue;
if (invisibleRecipientIds.Contains(recipientId))
continue;
string[] connectionIds;
lock (kvp.Value)
{
connectionIds = kvp.Value.ToArray();
}
foreach (var connectionId in connectionIds)
{
await Clients.Client(connectionId).SendAsync(eventName, payload);
}
}
}
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
// Chat methods // Chat methods
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────

View File

@@ -12,7 +12,6 @@
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" /> <ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" /> <ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" /> <ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -38,6 +37,7 @@
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" /> <InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
<InternalsVisibleTo Include="Knot.Modules.Conversations.UnitTests" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -55,11 +55,14 @@ public static class MessagesEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
}); });
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, CancellationToken ct) => group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, HttpRequest httpRequest, CancellationToken ct) =>
{ {
var attachments = request.Attachments?.Select(a => var attachments = request.Attachments?.Select(a =>
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList(); new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
// Получаем idempotency ключ из заголовка
httpRequest.Headers.TryGetValue("X-Idempotency-Key", out var idempotencyKey);
var command = new SendMessageCommand( var command = new SendMessageCommand(
chatId, chatId,
userContext.UserId, userContext.UserId,
@@ -68,7 +71,8 @@ public static class MessagesEndpoints
attachments, attachments,
request.ReplyToId, request.ReplyToId,
request.Quote, request.Quote,
request.ForwardedFromId); request.ForwardedFromId,
IdempotencyKey: idempotencyKey.ToString());
var result = await sender.Send(command, ct); var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);

View File

@@ -2,9 +2,5 @@ using System;
namespace Knot.Modules.Profiles.Application.Profiles.DTOs; namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
public record UpdateProfileRequest( public record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
string? DisplayName,
string? Bio,
DateTime? Birthday,
bool? IsInvisible);
public record UpdateSettingsRequest(bool? HideStoryViews); public record UpdateSettingsRequest(bool? HideStoryViews);

View File

@@ -11,8 +11,7 @@ public sealed record UpdateProfileCommand(
Guid UserId, Guid UserId,
string? DisplayName, string? DisplayName,
string? Bio, string? Bio,
DateTime? Birthday, DateTime? Birthday) : ICommand<UserProfileDto>;
bool? IsInvisible) : ICommand<UserProfileDto>;
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto> internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
{ {
@@ -29,13 +28,9 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
if (profile is null) if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound); return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
if ((request.Bio?.Length ?? 0) > 200)
return Result.Failure<UserProfileDto>(ProfilesErrors.BioTooLong);
profile.DisplayName = request.DisplayName ?? profile.DisplayName; profile.DisplayName = request.DisplayName ?? profile.DisplayName;
profile.About = request.Bio; profile.About = request.Bio;
profile.Birthday = request.Birthday; profile.Birthday = request.Birthday;
profile.IsInvisible = request.IsInvisible ?? profile.IsInvisible;
var result = await _repository.UpdateAsync(profile, cancellationToken); var result = await _repository.UpdateAsync(profile, cancellationToken);
if (result.IsFailure) if (result.IsFailure)

View File

@@ -1,21 +0,0 @@
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Profiles.Application.Statuses;
public sealed record ClearUserStatusCommand(Guid UserId) : IRequest<Result<UserProfileDto>>;
public sealed class ClearUserStatusCommandHandler : IRequestHandler<ClearUserStatusCommand, Result<UserProfileDto>>
{
private readonly IProfileStatusWriter _writer;
public ClearUserStatusCommandHandler(IProfileStatusWriter writer)
{
_writer = writer;
}
public Task<Result<UserProfileDto>> Handle(ClearUserStatusCommand request, CancellationToken cancellationToken)
=> _writer.ClearAsync(request.UserId, cancellationToken);
}

View File

@@ -1,12 +0,0 @@
using Knot.Contracts.Profiles.Application.DTOs;
using MediatR;
namespace Knot.Modules.Profiles.Application.Statuses;
public sealed record GetStatusPresetsQuery : IRequest<IReadOnlyList<StatusPresetDto>>;
public sealed class GetStatusPresetsQueryHandler : IRequestHandler<GetStatusPresetsQuery, IReadOnlyList<StatusPresetDto>>
{
public Task<IReadOnlyList<StatusPresetDto>> Handle(GetStatusPresetsQuery request, CancellationToken cancellationToken)
=> Task.FromResult(StatusPresetCatalog.All);
}

View File

@@ -1,53 +0,0 @@
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Profiles.Application.Statuses;
public sealed record SetCustomUserStatusCommand(
Guid UserId,
string? Emoji,
string? Text,
DateTime? ExpiresAt,
string? PresetKey) : IRequest<Result<UserProfileDto>>;
public sealed class SetCustomUserStatusCommandHandler : IRequestHandler<SetCustomUserStatusCommand, Result<UserProfileDto>>
{
private readonly IProfileStatusWriter _writer;
public SetCustomUserStatusCommandHandler(IProfileStatusWriter writer)
{
_writer = writer;
}
public async Task<Result<UserProfileDto>> Handle(SetCustomUserStatusCommand request, CancellationToken cancellationToken)
{
var key = request.PresetKey?.Trim();
if (!string.IsNullOrEmpty(key) && key.Equals("online", StringComparison.OrdinalIgnoreCase))
return await _writer.ClearAsync(request.UserId, cancellationToken);
var emoji = request.Emoji?.Trim() ?? string.Empty;
var text = request.Text?.Trim() ?? string.Empty;
if (!string.IsNullOrEmpty(key))
{
var preset = StatusPresetCatalog.Find(key);
if (preset is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.InvalidPreset);
if (string.IsNullOrEmpty(emoji))
emoji = preset.Emoji;
if (string.IsNullOrEmpty(text))
text = preset.TextRu;
}
if (string.IsNullOrEmpty(emoji) && string.IsNullOrEmpty(text))
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusEmpty);
if (text.Length > 50)
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusTextTooLong);
var presetForWriter = string.IsNullOrWhiteSpace(key) ? null : key;
return await _writer.SetCustomAsync(request.UserId, emoji, text, request.ExpiresAt, presetForWriter, cancellationToken);
}
}

View File

@@ -1,24 +0,0 @@
using Knot.Contracts.Profiles.Application.DTOs;
namespace Knot.Modules.Profiles.Application.Statuses;
public static class StatusPresetCatalog
{
private static readonly StatusPresetDto[] Items =
[
new() { Id = "online", Emoji = "", TextRu = "Онлайн (стандарт)", TextEn = "Online (standard)" },
new() { Id = "away", Emoji = "🕒", TextRu = "В отъезде", TextEn = "Away" },
new() { Id = "dnd", Emoji = "⛔", TextRu = "Не беспокоить", TextEn = "Do not disturb" },
new() { Id = "sick", Emoji = "🤒", TextRu = "Болен", TextEn = "Sick" },
new() { Id = "angry", Emoji = "💢", TextRu = "Злой", TextEn = "Angry" }
];
public static IReadOnlyList<StatusPresetDto> All => Items;
public static StatusPresetDto? Find(string presetKey)
{
if (string.IsNullOrWhiteSpace(presetKey))
return null;
return Items.FirstOrDefault(x => x.Id.Equals(presetKey.Trim(), StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -11,11 +11,10 @@ public static class DependencyInjection
public static IServiceCollection AddProfilesModule(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddProfilesModule(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileRepository, ProfileRepository>(); services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileRepository, ProfileRepository>();
services.AddScoped<Knot.Contracts.Profiles.Domain.IUserStatusRepository, UserStatusMongoRepository>();
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileStatusWriter, ProfileStatusWriter>();
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfilesUnitOfWork, ProfilesUnitOfWork>(); services.AddScoped<Knot.Contracts.Profiles.Domain.IProfilesUnitOfWork, ProfilesUnitOfWork>();
services.AddScoped<Knot.Contracts.Profiles.Domain.IAvatarStorageService, AvatarStorageService>(); services.AddScoped<Knot.Contracts.Profiles.Domain.IAvatarStorageService, AvatarStorageService>();
// MongoDB Registration
var mongoConnection = configuration.GetConnectionString("MongoConnection") var mongoConnection = configuration.GetConnectionString("MongoConnection")
?? configuration["MONGO_URL"] ?? configuration["MONGO_URL"]
?? "mongodb://mongo:27017"; ?? "mongodb://mongo:27017";

View File

@@ -3,6 +3,10 @@ using MongoDB.Bson.Serialization.Attributes;
namespace Knot.Modules.Profiles.Domain; namespace Knot.Modules.Profiles.Domain;
/// <summary>
/// MongoDB-документ профиля пользователя.
/// Id совпадает с UserId из модуля Auth (Postgres).
/// </summary>
public sealed class ProfileDocument public sealed class ProfileDocument
{ {
[BsonId] [BsonId]
@@ -15,23 +19,12 @@ public sealed class ProfileDocument
public string? Bio { get; private set; } public string? Bio { get; private set; }
public string? StatusText { get; private set; }
public string? StatusEmoji { get; private set; }
public DateTime? StatusExpiresAt { get; private set; }
[BsonRepresentation(BsonType.String)]
public Guid? CurrentStatusId { get; private set; }
public string? AvatarUrl { get; private set; } public string? AvatarUrl { get; private set; }
public DateTime? Birthday { get; private set; } public DateTime? Birthday { get; private set; }
public bool HideStoryViews { get; private set; } public bool HideStoryViews { get; private set; }
public bool IsInvisible { get; private set; }
public bool IsBanned { get; private set; } public bool IsBanned { get; private set; }
public bool IsDeleted { get; private set; } public bool IsDeleted { get; private set; }
@@ -72,26 +65,6 @@ public sealed class ProfileDocument
public void UpdateSettings(bool hideStoryViews) public void UpdateSettings(bool hideStoryViews)
=> HideStoryViews = hideStoryViews; => HideStoryViews = hideStoryViews;
public void UpdateInvisible(bool isInvisible)
=> IsInvisible = isInvisible;
public void UpdateStatus(string? statusText, string? statusEmoji, DateTime? statusExpiresAt)
{
StatusText = statusText;
StatusEmoji = statusEmoji;
StatusExpiresAt = statusExpiresAt;
}
public void SetCurrentStatusId(Guid? statusId)
=> CurrentStatusId = statusId;
public void ClearMoodStatusFields()
{
StatusText = null;
StatusEmoji = null;
StatusExpiresAt = null;
}
public void UpdateStatus(bool isBanned, bool isDeleted) public void UpdateStatus(bool isBanned, bool isDeleted)
{ {
IsBanned = isBanned; IsBanned = isBanned;

View File

@@ -1,26 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Knot.Modules.Profiles.Domain;
public sealed class UserStatusDocument
{
[BsonId]
[BsonRepresentation(BsonType.String)]
public Guid Id { get; set; }
[BsonRepresentation(BsonType.String)]
public Guid UserId { get; set; }
public string Type { get; set; } = "Custom";
public string Emoji { get; set; } = "";
public string Text { get; set; } = "";
public DateTime CreatedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public BsonDocument Metadata { get; set; } = new();
}

View File

@@ -10,39 +10,29 @@ namespace Knot.Modules.Profiles.Infrastructure.Database;
internal class ProfileRepository : IProfileRepository internal class ProfileRepository : IProfileRepository
{ {
private readonly IMongoCollection<ProfileDocument> _profiles; private readonly IMongoCollection<ProfileDocument> _profiles;
private readonly IUserStatusRepository _statuses;
public ProfileRepository(IMongoDatabase database, IUserStatusRepository statuses) public ProfileRepository(IMongoDatabase database)
{ {
_profiles = database.GetCollection<ProfileDocument>("profiles"); _profiles = database.GetCollection<ProfileDocument>("profiles");
_statuses = statuses;
} }
public async Task<UserProfileDto?> GetAsync(Guid userId, CancellationToken ct = default) public async Task<UserProfileDto?> GetAsync(Guid userId, CancellationToken ct = default)
{ {
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct); var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct);
return profile is null ? null : await profile.ToDtoAsync(_statuses, ct); return profile?.ToDto();
} }
public async Task<UserProfileDto?> GetByUsernameAsync(string username, CancellationToken ct = default) public async Task<UserProfileDto?> GetByUsernameAsync(string username, CancellationToken ct = default)
{ {
var profile = await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct); var profile = await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct);
return profile is null ? null : await profile.ToDtoAsync(_statuses, ct); return profile?.ToDto();
} }
public async Task<List<UserProfileDto>> GetAsync(IEnumerable<Guid> userIds, CancellationToken ct = default) public async Task<List<UserProfileDto>> GetAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
{ {
var ids = userIds.ToList(); var ids = userIds.ToList();
var profiles = await _profiles.Find(p => ids.Contains(p.Id)).ToListAsync(ct); var profiles = await _profiles.Find(p => ids.Contains(p.Id)).ToListAsync(ct);
var statusIds = profiles return profiles.Select(p => p.ToDto()).ToList();
.Where(p => p.CurrentStatusId.HasValue)
.Select(p => p.CurrentStatusId!.Value)
.Distinct()
.ToList();
var map = statusIds.Count > 0
? await _statuses.GetByIdsAsync(statusIds, ct)
: new Dictionary<Guid, UserStatusDto>();
return profiles.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList();
} }
public async Task<List<UserProfileDto>> SearchAsync(string query, int limit = 20, CancellationToken ct = default) public async Task<List<UserProfileDto>> SearchAsync(string query, int limit = 20, CancellationToken ct = default)
@@ -67,16 +57,7 @@ internal class ProfileRepository : IProfileRepository
var combinedFilter = Builders<ProfileDocument>.Filter.And(baseFilter, searchFilter); var combinedFilter = Builders<ProfileDocument>.Filter.And(baseFilter, searchFilter);
docs = await _profiles.Find(combinedFilter).Limit(limit).ToListAsync(ct); docs = await _profiles.Find(combinedFilter).Limit(limit).ToListAsync(ct);
} }
return docs.Select(p => p.ToDto()).ToList();
var statusIds = docs
.Where(p => p.CurrentStatusId.HasValue)
.Select(p => p.CurrentStatusId!.Value)
.Distinct()
.ToList();
var map = statusIds.Count > 0
? await _statuses.GetByIdsAsync(statusIds, ct)
: new Dictionary<Guid, UserStatusDto>();
return docs.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList();
} }
public async Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken ct = default) public async Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken ct = default)
@@ -84,7 +65,7 @@ internal class ProfileRepository : IProfileRepository
var profile = ProfileDocument.Create(dto.UserId, dto.Username ?? string.Empty, dto.DisplayName ?? string.Empty, dto.About); var profile = ProfileDocument.Create(dto.UserId, dto.Username ?? string.Empty, dto.DisplayName ?? string.Empty, dto.About);
await _profiles.InsertOneAsync(profile, null, ct); await _profiles.InsertOneAsync(profile, null, ct);
return Result.Success(await profile.ToDtoAsync(_statuses, ct)); return Result.Success(profile.ToDto());
} }
public async Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken ct = default) public async Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken ct = default)
@@ -99,13 +80,9 @@ internal class ProfileRepository : IProfileRepository
dto.Birthday); dto.Birthday);
document.UpdateAvatar(dto.Avatar); document.UpdateAvatar(dto.Avatar);
document.UpdateInvisible(dto.IsInvisible);
await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct); await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct);
var fresh = await _profiles.Find(p => p.Id == dto.UserId).FirstOrDefaultAsync(ct); return Result.Success(document.ToDto());
if (fresh is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
return Result.Success(await fresh.ToDtoAsync(_statuses, ct));
} }
public async Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default) public async Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default)

View File

@@ -1,73 +0,0 @@
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Domain;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Infrastructure.Mappings;
using Knot.Shared.Kernel;
using MongoDB.Driver;
namespace Knot.Modules.Profiles.Infrastructure.Database;
internal sealed class ProfileStatusWriter : IProfileStatusWriter
{
private readonly IMongoCollection<ProfileDocument> _profiles;
private readonly IUserStatusRepository _statuses;
public ProfileStatusWriter(IMongoDatabase database, IUserStatusRepository statuses)
{
_profiles = database.GetCollection<ProfileDocument>("profiles");
_statuses = statuses;
}
public async Task<Result<UserProfileDto>> SetCustomAsync(Guid userId, string emoji, string text, DateTime? expiresAt, string? presetKey, CancellationToken cancellationToken = default)
{
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
if (profile.CurrentStatusId is Guid oldId)
await _statuses.DeleteAsync(oldId, cancellationToken);
var newId = Guid.NewGuid();
var type = string.IsNullOrWhiteSpace(presetKey) ? "Custom" : "Preset";
var dto = new UserStatusDto
{
Id = newId,
Type = type,
Emoji = emoji,
Text = text,
CreatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt
};
await _statuses.InsertAsync(dto, userId, cancellationToken);
profile.SetCurrentStatusId(newId);
profile.ClearMoodStatusFields();
await _profiles.ReplaceOneAsync(p => p.Id == userId, profile, cancellationToken: cancellationToken);
var fresh = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
if (fresh is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken));
}
public async Task<Result<UserProfileDto>> ClearAsync(Guid userId, CancellationToken cancellationToken = default)
{
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
if (profile.CurrentStatusId is Guid oldId)
await _statuses.DeleteAsync(oldId, cancellationToken);
profile.SetCurrentStatusId(null);
profile.ClearMoodStatusFields();
await _profiles.ReplaceOneAsync(p => p.Id == userId, profile, cancellationToken: cancellationToken);
var fresh = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
if (fresh is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken));
}
}

View File

@@ -1,62 +0,0 @@
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Domain;
using Knot.Modules.Profiles.Domain;
using MongoDB.Bson;
using MongoDB.Driver;
namespace Knot.Modules.Profiles.Infrastructure.Database;
internal sealed class UserStatusMongoRepository : IUserStatusRepository
{
private readonly IMongoCollection<UserStatusDocument> _collection;
public UserStatusMongoRepository(IMongoDatabase database)
{
_collection = database.GetCollection<UserStatusDocument>("user_statuses");
}
public async Task<UserStatusDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
var doc = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken);
return doc is null ? null : ToDto(doc);
}
public async Task<IReadOnlyDictionary<Guid, UserStatusDto>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default)
{
var idList = ids.Distinct().ToList();
if (idList.Count == 0)
return new Dictionary<Guid, UserStatusDto>();
var docs = await _collection.Find(x => idList.Contains(x.Id)).ToListAsync(cancellationToken);
return docs.ToDictionary(d => d.Id, ToDto);
}
public async Task InsertAsync(UserStatusDto dto, Guid userId, CancellationToken cancellationToken = default)
{
var doc = new UserStatusDocument
{
Id = dto.Id,
UserId = userId,
Type = dto.Type,
Emoji = dto.Emoji,
Text = dto.Text,
CreatedAt = dto.CreatedAt,
ExpiresAt = dto.ExpiresAt,
Metadata = new BsonDocument()
};
await _collection.InsertOneAsync(doc, cancellationToken: cancellationToken);
}
public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
=> _collection.DeleteOneAsync(x => x.Id == id, cancellationToken);
private static UserStatusDto ToDto(UserStatusDocument d) => new()
{
Id = d.Id,
Type = d.Type,
Emoji = d.Emoji,
Text = d.Text,
CreatedAt = d.CreatedAt,
ExpiresAt = d.ExpiresAt
};
}

View File

@@ -1,14 +1,13 @@
using Knot.Contracts.Profiles.Application.DTOs; using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Domain;
using Knot.Modules.Profiles.Domain; using Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Infrastructure.Mappings; namespace Knot.Modules.Profiles.Infrastructure.Mappings;
public static class ProfileMappings public static class ProfileMappings
{ {
public static UserProfileDto ToDtoWithStatusMap(ProfileDocument document, IReadOnlyDictionary<Guid, UserStatusDto> statusMap) public static UserProfileDto ToDto(this ProfileDocument document)
{ {
var dto = new UserProfileDto return new UserProfileDto
{ {
UserId = document.Id, UserId = document.Id,
DisplayName = document.DisplayName, DisplayName = document.DisplayName,
@@ -19,59 +18,7 @@ public static class ProfileMappings
LastSeen = null, LastSeen = null,
Birthday = document.Birthday, Birthday = document.Birthday,
IsPremium = false, IsPremium = false,
IsInvisible = document.IsInvisible, CreatedAt = document.CreatedAt
CreatedAt = document.CreatedAt,
Status = null,
StatusText = null,
StatusEmoji = null,
StatusExpiresAt = null,
CurrentStatusId = null
}; };
UserStatusDto? active = null;
if (document.CurrentStatusId is Guid sid && statusMap.TryGetValue(sid, out var loaded) && loaded is not null)
{
var exp = loaded.ExpiresAt;
if (!exp.HasValue || exp.Value > DateTime.UtcNow)
active = loaded;
}
if (active is not null)
{
dto.Status = active;
dto.StatusText = active.Text;
dto.StatusEmoji = active.Emoji;
dto.StatusExpiresAt = active.ExpiresAt;
dto.CurrentStatusId = active.Id;
return dto;
}
var legacyExpired = document.StatusExpiresAt.HasValue && document.StatusExpiresAt.Value <= DateTime.UtcNow;
if (!legacyExpired && (!string.IsNullOrEmpty(document.StatusText) || !string.IsNullOrEmpty(document.StatusEmoji)))
{
dto.StatusText = document.StatusText;
dto.StatusEmoji = document.StatusEmoji;
dto.StatusExpiresAt = document.StatusExpiresAt;
dto.Status = new UserStatusDto
{
Id = Guid.Empty,
Type = "Custom",
Emoji = document.StatusEmoji ?? string.Empty,
Text = document.StatusText ?? string.Empty,
CreatedAt = document.CreatedAt,
ExpiresAt = document.StatusExpiresAt
};
return dto;
}
return dto;
}
public static async Task<UserProfileDto> ToDtoAsync(this ProfileDocument document, IUserStatusRepository statuses, CancellationToken cancellationToken)
{
if (document.CurrentStatusId is not Guid sid)
return ToDtoWithStatusMap(document, new Dictionary<Guid, UserStatusDto>());
var map = await statuses.GetByIdsAsync(new[] { sid }, cancellationToken);
return ToDtoWithStatusMap(document, map);
} }
} }

View File

@@ -17,8 +17,6 @@ public static class ProfilesEndpoints
public static void MapProfilesEndpoints(this WebApplication app) public static void MapProfilesEndpoints(this WebApplication app)
{ {
var group = app.MapGroup("api/profiles").RequireAuthorization(); var group = app.MapGroup("api/profiles").RequireAuthorization();
var userGroup = app.MapGroup("api/user").RequireAuthorization();
var usersGroup = app.MapGroup("api/users").RequireAuthorization();
group.MapGet("search", async ([FromQuery] string q, ISender sender, IUserContext userContext, CancellationToken ct) => group.MapGet("search", async ([FromQuery] string q, ISender sender, IUserContext userContext, CancellationToken ct) =>
{ {
@@ -69,37 +67,16 @@ public static class ProfilesEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
}); });
async Task<IResult> UpdateProfileHandler( group.MapPut("profile", async ([FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
[FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request,
ISender sender,
IUserContext userContext,
CancellationToken ct)
{ {
var result = await sender.Send( var result = await sender.Send(new UpdateProfileCommand(userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct);
new UpdateProfileCommand(
userContext.UserId,
request.DisplayName,
request.Bio,
request.Birthday,
request.IsInvisible),
ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
} });
group.MapPut("profile", UpdateProfileHandler);
group.MapPatch("profile", UpdateProfileHandler);
userGroup.MapPatch("profile", UpdateProfileHandler);
group.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => group.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
{ {
var result = await sender.Send(new GetProfileQuery(id), ct); var result = await sender.Send(new GetProfileQuery(id), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
}); });
usersGroup.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new GetProfileQuery(id), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
});
} }
} }

View File

@@ -1,45 +0,0 @@
using Knot.Modules.Profiles.Application.Statuses;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace Knot.Modules.Profiles.Presentation.Endpoints;
public static class StatusesEndpoints
{
public static void MapStatusesEndpoints(this WebApplication app)
{
var g = app.MapGroup("api/statuses").RequireAuthorization();
g.MapGet("presets", async (ISender sender, CancellationToken ct) =>
{
var list = await sender.Send(new GetStatusPresetsQuery(), ct);
return Results.Ok(list);
});
g.MapPost("custom", async ([FromBody] SetCustomStatusBody body, ISender sender, IUserContext ctx, CancellationToken ct) =>
{
var result = await sender.Send(
new SetCustomUserStatusCommand(ctx.UserId, body.Emoji, body.Text, body.ExpiresAt, body.PresetKey),
ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
g.MapDelete("/", async (ISender sender, IUserContext ctx, CancellationToken ct) =>
{
var result = await sender.Send(new ClearUserStatusCommand(ctx.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
}
public sealed class SetCustomStatusBody
{
public string? Emoji { get; set; }
public string? Text { get; set; }
public DateTime? ExpiresAt { get; set; }
public string? PresetKey { get; set; }
}
}

44
client-mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

20
client-mobile/.metadata Normal file
View File

@@ -0,0 +1,20 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
channel: stable
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
base_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
- platform: android
create_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
base_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb

View File

@@ -0,0 +1,125 @@
# Инструкция по запуску проекта
## Предварительные требования
1. **Flutter SDK** (версия 3.0.0 или выше)
- Установите Flutter: https://docs.flutter.dev/get-started/install
- Проверьте установку: `flutter doctor`
2. **Android Studio** или **VS Code** с Flutter плагином
3. **Android SDK** (для сборки под Android)
- MinSDK: 21
- TargetSDK: 34
- CompileSDK: 34
## Установка зависимостей
```bash
cd client-mobile
flutter pub get
```
## Генерация кода
Проект использует code generation для:
- Freezed (immutable модели)
- AutoRoute (навигация)
- Isar (база данных)
```bash
dart run build_runner build --delete-conflicting-outputs
```
Для автоматической генерации при изменениях:
```bash
dart run build_runner watch --delete-conflicting-outputs
```
## Запуск приложения
### Android
```bash
flutter run
```
### Сборка релиза
```bash
flutter build apk --release
```
## Структура проекта
```
lib/
├── core/ # Общие компоненты
│ ├── constants/ # Константы приложения
│ ├── errors/ # Обработка ошибок
│ ├── network/ # Сетевой клиент (Dio)
│ └── theme/ # Темы оформления
├── features/ # Функциональные модули
│ ├── auth/ # Аутентификация
│ │ ├── data/ # Data layer
│ │ ├── domain/ # Domain layer
│ │ └── presentation/ # UI layer (BLoC, страницы)
│ ├── chat/ # Чаты
│ ├── profile/ # Профиль
│ └── settings/ # Настройки
└── internal/ # Внутренняя конфигурация
├── di/ # Dependency Injection (GetIt)
└── router/ # Навигация
```
## Архитектура
Проект следует принципам **Clean Architecture**:
- **Domain Layer**: Бизнес-логика, entities, use cases, repository interfaces
- **Data Layer**: Реализации репозиториев, datasources, модели
- **Presentation Layer**: UI, BLoC, страницы, виджеты
## State Management
Используется **flutter_bloc** для управления состоянием:
- Каждый feature имеет свой BLoC
- События и состояния генерируются через Freezed
- DI через GetIt
## Навигация
Используется **auto_route** для декларативной навигации.
## База данных
Используется **Isar** - быстрая NoSQL база данных для Flutter.
## Сетевые запросы
- **Dio** для REST API запросов
- **SignalR** для real-time обновлений
## Тестирование
```bash
flutter test
```
## Полезные команды
```bash
# Анализ кода
flutter analyze
# Форматирование
dart format .
# Очистка
flutter clean
# Проверка зависимостей
flutter pub outdated
```
## Контакты
Для вопросов и предложений обращайтесь к команде разработки.

51
client-mobile/README.md Normal file
View File

@@ -0,0 +1,51 @@
# Messenger App
A Telegram-like mobile messenger application built with Flutter using Clean Architecture.
## Architecture
This project follows Clean Architecture principles with the following structure:
```
lib/
├── core/ # Core utilities, network, errors, theme
├── features/ # Feature modules (auth, chat, profile, settings)
│ └── [feature]/
│ ├── data/ # Data layer (repositories, datasources)
│ ├── domain/ # Domain layer (entities, usecases, repository interfaces)
│ └── presentation/ # UI layer (bloc, pages, widgets)
└── internal/ # App configuration, DI, routing
```
## Dependencies
- **State Management**: flutter_bloc, bloc
- **Architecture & DI**: get_it, injectable, freezed_annotation
- **Network & Real-time**: dio, signalr_netcore
- **Navigation**: auto_route
- **Database**: isar, isar_flutter_libs
## Setup
1. Install Flutter dependencies:
```bash
flutter pub get
```
2. Generate code:
```bash
dart run build_runner build --delete-conflicting-outputs
```
3. Run the app:
```bash
flutter run
```
## Features
- Authentication (Login/Register)
- Real-time Chat
- Contacts
- Profile Management
- Settings

View File

@@ -0,0 +1,8 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
prefer_const_declarations: true
avoid_print: false
prefer_single_quotes: true

7
client-mobile/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java

View File

@@ -0,0 +1,69 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
namespace "com.example.messenger_app"
compileSdkVersion 36
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "com.example.messenger_app"
minSdkVersion flutter.minSdkVersion
targetSdkVersion 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
buildTypes {
release {
signingConfig signingConfigs.debug
}
}
}
flutter {
source '../..'
}
dependencies {}

View File

@@ -0,0 +1,28 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="Messenger"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.example.messenger_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
</layer-list>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2481CC" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2481CC" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2481CC" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2481CC" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2481CC" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,25 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
afterEvaluate { project ->
if (project.hasProperty("android")) {
project.android {
if (namespace == null) {
namespace project.group
}
}
}
}
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip

View File

@@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"

View File

@@ -0,0 +1,14 @@
// This is a generated file; do not edit or check into version control.
FLUTTER_ROOT=C:\src\flutter
FLUTTER_APPLICATION_PATH=E:\GIT\forkmessager\client-mobile
COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_TARGET=lib\main.dart
FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.0
FLUTTER_BUILD_NUMBER=1
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false
PACKAGE_CONFIG=.dart_tool/package_config.json

View File

@@ -0,0 +1,32 @@
#
# Generated file, do not edit.
#
import lldb
def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict):
"""Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages."""
base = frame.register["x0"].GetValueAsAddress()
page_len = frame.register["x1"].GetValueAsUnsigned()
# Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the
# first page to see if handled it correctly. This makes diagnosing
# misconfiguration (e.g. missing breakpoint) easier.
data = bytearray(page_len)
data[0:8] = b'IHELPED!'
error = lldb.SBError()
frame.GetThread().GetProcess().WriteMemory(base, data, error)
if not error.Success():
print(f'Failed to write into {base}[+{page_len}]', error)
return
def __lldb_init_module(debugger: lldb.SBDebugger, _):
target = debugger.GetDummyTarget()
# Caveat: must use BreakpointCreateByRegEx here and not
# BreakpointCreateByName. For some reasons callback function does not
# get carried over from dummy target for the later.
bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$")
bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__))
bp.SetAutoContinue(True)
print("-- LLDB integration loaded --")

View File

@@ -0,0 +1,5 @@
#
# Generated file, do not edit.
#
command script import --relative-to-command-file flutter_lldb_helper.py

View File

@@ -0,0 +1,13 @@
#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=C:\src\flutter"
export "FLUTTER_APPLICATION_PATH=E:\GIT\forkmessager\client-mobile"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib\main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"

View File

@@ -0,0 +1,13 @@
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -0,0 +1,19 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GeneratedPluginRegistrant_h
#define GeneratedPluginRegistrant_h
#import <Flutter/Flutter.h>
NS_ASSUME_NONNULL_BEGIN
@interface GeneratedPluginRegistrant : NSObject
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
@end
NS_ASSUME_NONNULL_END
#endif /* GeneratedPluginRegistrant_h */

View File

@@ -0,0 +1,28 @@
//
// Generated file. Do not edit.
//
// clang-format off
#import "GeneratedPluginRegistrant.h"
#if __has_include(<isar_flutter_libs/IsarFlutterLibsPlugin.h>)
#import <isar_flutter_libs/IsarFlutterLibsPlugin.h>
#else
@import isar_flutter_libs;
#endif
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
#else
@import shared_preferences_foundation;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[IsarFlutterLibsPlugin registerWithRegistrar:[registry registrarForPlugin:@"IsarFlutterLibsPlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
}
@end

6
client-mobile/l10n.yaml Normal file
View File

@@ -0,0 +1,6 @@
arb-dir: l10n
template-arb-file: app_ru.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
output-dir: lib/l10n
synthetic-package: false

View File

@@ -0,0 +1,93 @@
{
"@@locale": "en",
"appTitle": "Messenger",
"chats": "Chats",
"favorites": "Favorites",
"contacts": "Contacts",
"settings": "Settings",
"login": "Login",
"username": "Login",
"password": "Password",
"name": "Name",
"loginButton": "Sign In",
"register": "Register",
"noAccount": "No account? Sign up",
"logout": "Sign Out",
"serverSettings": "Server Settings",
"apiUrl": "API URL",
"apiUrlHint": "https://api.example.com",
"save": "Save",
"serverConfig": "Server Configuration",
"system": "System",
"stories": "Stories",
"chatsModule": "Chats",
"messages": "Messages",
"calls": "Calls",
"klipy": "Klipy",
"import": "Import",
"federation": "Federation",
"domainUrl": "Domain",
"registrationStatus": "Registration",
"enabled": "Enabled",
"disabled": "Disabled",
"language": "Language",
"russian": "Русский",
"english": "English",
"notifications": "Notifications",
"privacy": "Privacy",
"soundsAndVibration": "Sounds & Vibration",
"dataAndStorage": "Data & Storage",
"aboutApp": "About App",
"help": "Help",
"version": "Version",
"loading": "Loading...",
"error": "Error",
"retry": "Retry",
"noChats": "No chats yet",
"inDevelopment": "In development",
"user": "User",
"edit": "Edit",
"serverConnectionError": "Server connection error",
"connected": "Connected",
"unknownError": "Unknown error",
"networkError": "Network error",
"serverError": "Server error",
"parsingError": "Data parsing error",
"voice": "Voice Calls",
"video": "Video Calls",
"lifetime": "Lifetime",
"groups": "Groups",
"maxParticipants": "Max participants",
"hours": "h",
"dailyLimit": "Daily Limit",
"historyLimit": "History Limit",
"maxFileSize": "Max File Size",
"noLimit": "No Limit",
"allowForwarding": "Forwarding",
"allowReactions": "Reactions",
"allowReplies": "Replies",
"allowQuoting": "Quoting",
"allowMessageDeletion": "Deletion",
"forbidCopying": "Forbid Copying",
"allowLinks": "Links",
"allowPolls": "Polls",
"allowPinning": "Pins",
"allowMedia": "Media",
"description": "Description",
"server": "Server",
"notSpecified": "not specified",
"settingsSaved": "Settings saved",
"enterUsername": "Enter login",
"invalidUsername": "Login must be at least 3 characters and without Cyrillic",
"loginFailed": "Login failed. Check your login and password",
"registrationFailed": "Registration failed",
"online": "online",
"lastSeen": "last seen {date} at {time}",
"lastSeenRelative": "last seen {hours}h {minutes}m ago",
"lastSeenRelativeMinutes": "last seen {minutes}m ago",
"lastSeenJustNow": "last seen just now",
"lastSeenYesterday": "last seen yesterday at {time}",
"lastSeenDayBeforeYesterday": "last seen day before yesterday at {time}",
"noMessages": "No messages yet",
"messageHint": "Message"
}

View File

@@ -0,0 +1,93 @@
{
"@@locale": "ru",
"appTitle": "Мессенджер",
"chats": "Чаты",
"favorites": "Избранное",
"contacts": "Контакты",
"settings": "Настройки",
"login": "Вход",
"username": "Логин",
"password": "Пароль",
"name": "Имя",
"loginButton": "Войти",
"register": "Зарегистрироваться",
"noAccount": "Нет аккаунта? Зарегистрироваться",
"logout": "Выйти из аккаунта",
"serverSettings": "Настройки сервера",
"apiUrl": "Адрес API",
"apiUrlHint": "https://api.example.com",
"save": "Сохранить",
"serverConfig": "Конфигурация сервера",
"system": "Система",
"stories": "Истории",
"chatsModule": "Чаты",
"messages": "Сообщения",
"calls": "Звонки",
"klipy": "Клипы",
"import": "Импорт",
"federation": "Федерация",
"domainUrl": "Домен",
"registrationStatus": "Регистрация",
"enabled": "Включено",
"disabled": "Отключено",
"language": "Язык",
"russian": "Русский",
"english": "English",
"notifications": "Уведомления",
"privacy": "Конфиденциальность",
"soundsAndVibration": "Звуки и вибрация",
"dataAndStorage": "Данные и память",
"aboutApp": "О приложении",
"help": "Помощь",
"version": "Версия",
"loading": "Загрузка...",
"error": "Ошибка",
"retry": "Повторить",
"noChats": "У вас пока нет чатов",
"inDevelopment": "Функция в разработке",
"user": "Пользователь",
"edit": "Редактировать",
"serverConnectionError": "Ошибка подключения к серверу",
"connected": "Подключено",
"unknownError": "Неизвестная ошибка",
"networkError": "Ошибка сети",
"serverError": "Ошибка сервера",
"parsingError": "Ошибка обработки данных",
"voice": "Голосовые вызовы",
"video": "Видеовызовы",
"lifetime": "Время жизни",
"groups": "Группы",
"maxParticipants": "Макс. участников",
"hours": "ч.",
"dailyLimit": "Дневной лимит",
"historyLimit": "Лимит истории",
"maxFileSize": "Макс. файл",
"noLimit": "Без лимита",
"allowForwarding": "Пересылка",
"allowReactions": "Реакции",
"allowReplies": "Ответы",
"allowQuoting": "Цитирование",
"allowMessageDeletion": "Удаление",
"forbidCopying": "Запрет копирования",
"allowLinks": "Ссылки",
"allowPolls": "Опросы",
"allowPinning": "Закрепы",
"allowMedia": "Медиа",
"description": "Описание",
"server": "Сервер",
"notSpecified": "не указан",
"settingsSaved": "Настройки сохранены",
"enterUsername": "Введите логин",
"invalidUsername": "Логин должен быть от 3 символов и без кириллицы",
"loginFailed": "Ошибка входа. Проверьте логин и пароль",
"registrationFailed": "Ошибка регистрации",
"online": "в сети",
"lastSeen": "был(а) в сети {date} в {time}",
"lastSeenRelative": "был(а) {hours} ч. {minutes} мин. назад",
"lastSeenRelativeMinutes": "был(а) {minutes} мин. назад",
"lastSeenJustNow": "был(а) только что",
"lastSeenYesterday": "был(а) вчера в {time}",
"lastSeenDayBeforeYesterday": "был(а) позавчера в {time}",
"noMessages": "Сообщений пока нет",
"messageHint": "Сообщение"
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'core/theme/app_theme.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/presentation/bloc/auth_event.dart';
import 'features/chat/presentation/bloc/chat_bloc.dart';
import 'features/chat/presentation/bloc/chat_event.dart';
import 'internal/di/injection_container.dart' as di;
import 'features/settings/presentation/bloc/settings_bloc.dart';
import 'features/settings/presentation/bloc/settings_event.dart';
import 'features/settings/presentation/bloc/settings_state.dart';
import 'l10n/app_localizations.dart';
import 'internal/router/main_screen.dart';
import 'features/auth/presentation/pages/login_page.dart';
import 'features/auth/presentation/bloc/auth_state.dart';
class MessengerApp extends StatelessWidget {
const MessengerApp({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<AuthBloc>(
create: (_) => di.sl<AuthBloc>()..add(const AuthEvent.authChecked()),
),
BlocProvider<ChatBloc>(
create: (_) => di.sl<ChatBloc>()..add(const ChatEvent.started()),
),
BlocProvider<SettingsBloc>(
create: (_) => di.sl<SettingsBloc>()..add(const SettingsEvent.started()),
),
],
child: BlocBuilder<SettingsBloc, SettingsState>(
builder: (context, state) {
final locale = state.maybeWhen(
loaded: (_, __, languageCode, ___, ____) => Locale(languageCode),
orElse: () => const Locale('ru'),
);
return MaterialApp(
title: 'Messenger',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
locale: locale,
supportedLocales: const [
Locale('ru'),
Locale('en'),
],
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: BlocListener<AuthBloc, AuthState>(
listener: (context, authState) {
authState.maybeWhen(
authenticated: (userId) {
context.read<ChatBloc>().add(ChatEvent.started(userId: userId));
},
unauthenticated: () {
context.read<ChatBloc>().add(const ChatEvent.cacheCleared());
},
orElse: () {},
);
},
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) {
return authState.maybeWhen(
authenticated: (_) => const MainScreen(),
orElse: () => const LoginPage(),
);
},
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,25 @@
class AppConstants {
AppConstants._();
// API
static const String apiBaseUrl = 'https://api.messenger.app';
static const int apiTimeout = 30;
// Storage
static const String sharedPrefsName = 'messenger_prefs';
static const String tokenKey = 'auth_token';
static const String refreshTokenKey = 'refresh_token';
static const String userIdKey = 'user_id';
// Pagination
static const int pageSize = 20;
static const int defaultPageSize = 50;
// Chat
static const int maxMessageLength = 4096;
static const int typingIndicatorTimeout = 3000;
// Media
static const int maxImageSize = 10 * 1024 * 1024; // 10MB
static const int maxVideoSize = 100 * 1024 * 1024; // 100MB
}

View File

@@ -0,0 +1,9 @@
class StorageKeys {
StorageKeys._();
static const String apiUrl = 'api_url';
static const String languageCode = 'language_code';
static const String serverConfig = 'server_config';
static const String authToken = 'auth_token';
static const String refreshToken = 'refresh_token';
}

View File

@@ -0,0 +1,22 @@
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import '../../features/chat/data/models/chat_model.dart';
import '../../features/chat/data/models/message_model.dart';
class DatabaseService {
late Isar _isar;
Isar get isar => _isar;
Future<void> init() async {
final dir = await getApplicationDocumentsDirectory();
_isar = await Isar.open(
[ChatModelSchema, MessageModelSchema],
directory: dir.path,
);
}
Future<void> clearAll() async {
await _isar.writeTxn(() => _isar.clear());
}
}

View File

@@ -0,0 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'errors.freezed.dart';
@freezed
class AppError with _$AppError {
const factory AppError.unknown({String? message}) = UnknownError;
const factory AppError.network({String? message}) = NetworkError;
const factory AppError.unauthorized({String? message}) = UnauthorizedError;
const factory AppError.forbidden({String? message}) = ForbiddenError;
const factory AppError.notFound({String? message}) = NotFoundError;
const factory AppError.server({int? statusCode, String? message}) = ServerError;
const factory AppError.database({String? message}) = DatabaseError;
const factory AppError.validation({String? message, Map<String, String>? fieldErrors}) = ValidationError;
const factory AppError.parsing({String? message}) = ParsingError;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
import 'errors.dart';
/// A generic class for handling success/failure results
class Result<T> {
final T? data;
final AppError? error;
const Result.success(this.data) : error = null;
const Result.failure(this.error) : data = null;
bool get isSuccess => error == null;
bool get isFailure => error != null;
R when<R>({
required R Function(T data) onSuccess,
required R Function(AppError error) onFailure,
}) {
if (isSuccess) {
return onSuccess(data as T);
}
return onFailure(error!);
}
R fold<R>(
R Function(AppError error) onFailure,
R Function(T data) onSuccess,
) {
if (isSuccess) {
return onSuccess(data as T);
}
return onFailure(error!);
}
Result<R> map<R>(R Function(T data) transform) {
if (isSuccess) {
return Result.success(transform(data as T));
}
return Result.failure(error!);
}
T getOrThrow() {
if (isFailure) {
throw error!;
}
return data as T;
}
}

View File

@@ -0,0 +1,135 @@
import 'package:dio/dio.dart';
import '../errors/errors.dart';
import '../errors/result.dart';
class ApiClient {
final Dio _dio;
ApiClient(this._dio);
Future<Result<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
final response = await _dio.get<T>(
path,
queryParameters: queryParameters,
options: options,
);
return Result.success(response.data as T);
} on DioException catch (e) {
return Result.failure(_handleDioError(e));
} catch (e) {
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
}
}
Future<Result<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
final response = await _dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
return Result.success(response.data as T);
} on DioException catch (e) {
return Result.failure(_handleDioError(e));
} catch (e) {
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
}
}
Future<Result<T>> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
final response = await _dio.put<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
return Result.success(response.data as T);
} on DioException catch (e) {
return Result.failure(_handleDioError(e));
} catch (e) {
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
}
}
Future<Result<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
final response = await _dio.delete<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
return Result.success(response.data as T);
} on DioException catch (e) {
return Result.failure(_handleDioError(e));
} catch (e) {
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
}
}
AppError _handleDioError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return const AppError.network(message: 'Connection timeout');
case DioExceptionType.connectionError:
return const AppError.network(message: 'No internet connection');
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
switch (statusCode) {
case 401:
return const AppError.unauthorized();
case 403:
return const AppError.forbidden();
case 404:
return const AppError.notFound();
case 422:
return AppError.validation(fieldErrors: _extractValidationErrors(error.response?.data));
case int s when s >= 500:
return AppError.server(statusCode: statusCode, message: 'Server error');
default:
return AppError.server(statusCode: statusCode, message: 'Request failed');
}
case DioExceptionType.cancel:
return const AppError.unknown(message: 'Request cancelled');
default:
return const AppError.unknown();
}
}
Map<String, String>? _extractValidationErrors(dynamic data) {
if (data is Map<String, dynamic> && data.containsKey('errors')) {
final errors = data['errors'] as Map<String, dynamic>;
return errors.map((key, value) {
if (value is List && value.isNotEmpty) {
return MapEntry(key, value.first.toString());
}
return MapEntry(key, 'Invalid');
});
}
return null;
}
}

View File

@@ -0,0 +1,190 @@
import 'dart:async';
import 'package:signalr_netcore/signalr_client.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/chat/domain/entities/message.dart';
class SignalRService {
final SharedPreferences prefs;
HubConnection? _hubConnection;
final _messageController = StreamController<Message>.broadcast();
final _typingController = StreamController<Map<String, dynamic>>.broadcast();
final _statusController = StreamController<Map<String, dynamic>>.broadcast();
final _readReceiptController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Message> get messages => _messageController.stream;
Stream<Map<String, dynamic>> get typingUpdates => _typingController.stream;
Stream<Map<String, dynamic>> get statusUpdates => _statusController.stream;
Stream<Map<String, dynamic>> get readReceipts => _readReceiptController.stream;
SignalRService(this.prefs);
Future<void> init() async {
if (_hubConnection?.state == HubConnectionState.Connected ||
_hubConnection?.state == HubConnectionState.Connecting) {
// ignore: avoid_print
print('[SignalR] Connection already active or connecting. Skipping init.');
return;
}
if (_hubConnection != null) {
await stop();
}
final apiUrl = prefs.getString('api_url') ?? 'https://api.messenger.app';
final token = prefs.getString('access_token');
if (token == null) return;
final hubUrl = '${apiUrl.replaceAll(RegExp(r'/+$'), '')}/hubs/chat';
// ignore: avoid_print
print('[SignalR] Initializing connection to: $hubUrl');
_hubConnection = HubConnectionBuilder()
.withUrl(hubUrl, options: HttpConnectionOptions(
accessTokenFactory: () async => token,
))
.withAutomaticReconnect()
.build();
_hubConnection?.onclose(({error}) {
// ignore: avoid_print
print('[SignalR] Connection closed. Error: $error');
});
_hubConnection?.onreconnecting(({error}) {
// ignore: avoid_print
print('[SignalR] Reconnecting... Error: $error');
});
_hubConnection?.onreconnected(({connectionId}) {
// ignore: avoid_print
print('[SignalR] Reconnected! ID: $connectionId');
});
// Event names verified from backend source code (MessageNotifier.cs)
_hubConnection?.on('new_message', _handleReceiveMessage);
_hubConnection?.on('new_message_received', _handleReceiveMessage); // Optional fallback
_hubConnection?.on('user_typing', _handleUserTyping);
_hubConnection?.on('user_stopped_typing', _handleUserStoppedTyping);
_hubConnection?.on('user_online', (args) {
// ignore: avoid_print
print('[SignalR] user_online: $args');
if (args != null && args.isNotEmpty) {
final data = args[0] as Map<String, dynamic>;
_statusController.add({'userId': data['userId'] ?? data['UserId'], 'isOnline': true});
}
});
_hubConnection?.on('user_offline', (args) {
// ignore: avoid_print
print('[SignalR] user_offline: $args');
if (args != null && args.isNotEmpty) {
final data = args[0] as Map<String, dynamic>;
_statusController.add({
'userId': data['userId'] ?? data['UserId'],
'isOnline': false,
'lastSeen': data['lastSeen'] ?? data['LastSeen']
});
}
});
_hubConnection?.on('messages_read', (args) {
// ignore: avoid_print
print('[SignalR] messages_read: $args');
if (args != null && args.isNotEmpty) {
final data = args[0] as Map<String, dynamic>;
_readReceiptController.add({
'chatId': data['chatId'] ?? data['ChatId'],
'userId': data['userId'] ?? data['UserId'],
'lastReadMessageId': data['lastReadMessageId'] ?? data['LastReadMessageId'],
'lastReadSequenceId': data['lastReadSequenceId'] ?? data['LastReadSequenceId'],
});
}
});
try {
// ignore: avoid_print
print('[SignalR] Starting connection...');
await _hubConnection?.start();
// ignore: avoid_print
print('[SignalR] Connection started successfully. State: ${_hubConnection?.state}');
} catch (e) {
// ignore: avoid_print
print('[SignalR] Connection failed: $e');
}
}
Future<void> markMessagesAsRead(String chatId, String lastReadMessageId, int lastReadSequenceId) async {
// ignore: avoid_print
print('[SignalR] markMessagesAsRead: chatId=$chatId, msgId=$lastReadMessageId, seqId=$lastReadSequenceId');
await _hubConnection?.invoke('read_messages', args: [
{
'ChatId': chatId,
'LastReadMessageId': lastReadMessageId,
'LastReadSequenceId': lastReadSequenceId,
}
]);
}
void _handleUserTyping(List<dynamic>? arguments) {
// ignore: avoid_print
print('[SignalR] _handleUserTyping: $arguments');
if (arguments != null && arguments.isNotEmpty) {
final data = arguments[0] as Map<String, dynamic>;
_typingController.add({'chatId': data['ChatId'] ?? data['chatId'], 'userId': data['UserId'] ?? data['userId'], 'isTyping': true});
}
}
void _handleUserStoppedTyping(List<dynamic>? arguments) {
// ignore: avoid_print
print('[SignalR] _handleUserStoppedTyping: $arguments');
if (arguments != null && arguments.isNotEmpty) {
final data = arguments[0] as Map<String, dynamic>;
_typingController.add({'chatId': data['ChatId'] ?? data['chatId'], 'userId': data['UserId'] ?? data['userId'], 'isTyping': false});
}
}
Future<void> sendTypingStatus(String chatId, bool isTyping) async {
if (isTyping) {
await _hubConnection?.invoke('typing_start', args: [chatId]);
} else {
await _hubConnection?.invoke('typing_stop', args: [chatId]);
}
}
void _handleReceiveMessage(List<dynamic>? arguments) {
// ignore: avoid_print
print('[SignalR] _handleReceiveMessage: $arguments');
if (arguments != null && arguments.isNotEmpty) {
final data = arguments[0] as Map<String, dynamic>;
final message = Message(
id: (data['id'] ?? data['Id'] ?? '').toString(),
chatId: (data['chatId'] ?? data['ChatId'] ?? '').toString(),
senderId: (data['senderId'] ?? data['SenderId'] ?? data['userId'] ?? data['UserId'] ?? '').toString(),
content: data['content'] ?? data['Content'] ?? '',
messageType: (data['type'] ?? data['Type'] ?? 'text').toString().toLowerCase(),
sequenceId: int.tryParse((data['sequenceId'] ?? data['SequenceId'] ?? '0').toString()) ?? 0,
createdAt: _parseDateTime(data['createdAt'] ?? data['CreatedAt']),
updatedAt: _parseDateTime(data['updatedAt'] ?? data['UpdatedAt']),
status: (data['isRead'] ?? data['IsRead'] ?? false) ? MessageStatus.read : MessageStatus.delivered,
media: (data['media'] ?? data['Media']) is Map<String, dynamic>
? (data['media'] ?? data['Media']) as Map<String, dynamic>
: null,
);
_messageController.add(message);
}
}
DateTime? _parseDateTime(String? dateStr) {
if (dateStr == null) return null;
String normalized = dateStr;
if (!normalized.endsWith('Z') && !normalized.contains('+')) {
normalized += 'Z';
}
return DateTime.parse(normalized).toLocal();
}
Future<void> stop() async {
await _hubConnection?.stop();
_hubConnection = null;
}
}

View File

@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class AppTheme {
AppTheme._();
static const _primaryColor = Color(0xFF2481CC);
static const _primaryContainerColor = Color(0xFFD3E4FD);
static const _secondaryColor = Color(0xFF5F6AC4);
static const _tertiaryColor = Color(0xFFFFD700);
static const _lightSurfaceColor = Color(0xFFFFFBFE);
static const _darkSurfaceColor = Color(0xFF1C1B1F);
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: const ColorScheme.light(
primary: _primaryColor,
primaryContainer: _primaryContainerColor,
secondary: _secondaryColor,
tertiary: _tertiaryColor,
surface: _lightSurfaceColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: Colors.black87,
error: Color(0xFFBA1A1A),
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: _primaryColor,
foregroundColor: Colors.white,
systemOverlayStyle: SystemUiOverlayStyle.light,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
type: BottomNavigationBarType.fixed,
backgroundColor: Colors.white,
selectedItemColor: _primaryColor,
unselectedItemColor: Colors.grey,
elevation: 8,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: const ColorScheme.dark(
primary: _primaryColor,
primaryContainer: _primaryContainerColor,
secondary: _secondaryColor,
tertiary: _tertiaryColor,
surface: _darkSurfaceColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: const Color(0xFFE6E1E5),
error: Color(0xFFFFB4AB),
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF1F1F1F),
foregroundColor: Colors.white,
systemOverlayStyle: SystemUiOverlayStyle.light,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
type: BottomNavigationBarType.fixed,
backgroundColor: Color(0xFF1F1F1F),
selectedItemColor: _primaryColor,
unselectedItemColor: Colors.grey,
elevation: 8,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF2D2D2D),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
}

View File

@@ -0,0 +1,11 @@
abstract class AuthLocalDataSource {
Future<String?> getToken();
String? getTokenSync();
Future<void> saveToken(String token);
Future<String?> getRefreshToken();
Future<void> saveRefreshToken(String token);
Future<void> removeToken();
Future<String?> getUserId();
Future<void> saveUserId(String userId);
Future<void> removeUserId();
}

View File

@@ -0,0 +1,58 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'auth_local_datasource.dart';
class AuthLocalDataSourceImpl implements AuthLocalDataSource {
final SharedPreferences sharedPreferences;
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userIdKey = 'user_id';
AuthLocalDataSourceImpl(this.sharedPreferences);
@override
Future<String?> getToken() async {
return sharedPreferences.getString(_accessTokenKey);
}
@override
String? getTokenSync() {
return sharedPreferences.getString(_accessTokenKey);
}
Future<String?> getRefreshToken() async {
return sharedPreferences.getString(_refreshTokenKey);
}
@override
Future<void> saveToken(String token) async {
// ignore: avoid_print
print('[DEBUG] Saving new access token (starts with: ${token.substring(0, 10)}...)');
await sharedPreferences.setString(_accessTokenKey, token);
}
Future<void> saveRefreshToken(String token) async {
await sharedPreferences.setString(_refreshTokenKey, token);
}
@override
Future<void> removeToken() async {
await sharedPreferences.remove(_accessTokenKey);
await sharedPreferences.remove(_refreshTokenKey);
}
@override
Future<String?> getUserId() async {
return sharedPreferences.getString(_userIdKey);
}
@override
Future<void> saveUserId(String userId) async {
await sharedPreferences.setString(_userIdKey, userId);
}
@override
Future<void> removeUserId() async {
await sharedPreferences.remove(_userIdKey);
}
}

View File

@@ -0,0 +1,11 @@
import '../../../../core/errors/result.dart';
import '../../domain/entities/user.dart';
import '../models/auth_response.dart';
abstract class AuthRemoteDataSource {
Future<Result<AuthResponse>> login(String username, String password);
Future<Result<AuthResponse>> register(String username, String password, String name);
Future<Result<void>> logout();
Future<Result<User>> getCurrentUser();
Future<Result<void>> refreshToken();
}

View File

@@ -0,0 +1,87 @@
import '../../../../core/errors/errors.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/errors/result.dart';
import '../../domain/entities/user.dart';
import '../models/auth_response.dart';
import 'auth_remote_datasource.dart';
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final ApiClient apiClient;
AuthRemoteDataSourceImpl(this.apiClient);
@override
Future<Result<AuthResponse>> login(String username, String password) async {
final result = await apiClient.post<Map<String, dynamic>>(
'/api/auth/login',
data: {
'username': username,
'password': password,
},
);
return result.fold(
(error) => Result.failure(error),
(data) {
try {
return Result.success(AuthResponse.fromJson(data));
} catch (e) {
return Result.failure(AppError.parsing(message: 'Invalid server response format: $e'));
}
},
);
}
@override
Future<Result<AuthResponse>> register(String username, String password, String name) async {
final result = await apiClient.post<Map<String, dynamic>>(
'/api/auth/register',
data: {
'username': username,
'password': password,
'displayName': name,
},
);
return result.fold(
(error) => Result.failure(error),
(data) {
try {
return Result.success(AuthResponse.fromJson(data));
} catch (e) {
return Result.failure(AppError.parsing(message: 'Invalid server response format: $e'));
}
},
);
}
@override
Future<Result<void>> logout() async {
return await apiClient.post('/api/auth/logout');
}
@override
Future<Result<User>> getCurrentUser() async {
final result = await apiClient.get<Map<String, dynamic>>('/api/auth/me');
return result.fold(
(error) => Result.failure(error),
(data) {
try {
return Result.success(User(
id: data['userId'] as String,
email: data['username'] as String,
name: data['displayName'] as String,
avatarUrl: data['avatar'] as String?,
));
} catch (e) {
return Result.failure(AppError.parsing(message: 'Invalid user data format: $e'));
}
},
);
}
@override
Future<Result<void>> refreshToken() async {
return await apiClient.post('/api/auth/refresh');
}
}

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