Compare commits
47 Commits
bugfix_web
...
android_v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a426b63b7d | ||
|
|
4d4bc8edd1 | ||
|
|
40589dbb75 | ||
|
|
0b013def2e | ||
|
|
ccffc5a53c | ||
|
|
e8161d23d4 | ||
|
|
89e325556c | ||
|
|
df4feeeee5 | ||
|
|
00ed4b5959 | ||
|
|
a0b50b57b0 | ||
|
|
d4c41b333a | ||
|
|
9daa786cfb | ||
|
|
2d2e4b685e | ||
|
|
46c22300e9 | ||
|
|
d9a20e40b0 | ||
|
|
945134f029 | ||
|
|
f6400ce3ac | ||
|
|
d9462069e2 | ||
|
|
9e715fe3ab | ||
|
|
8c8ef55b58 | ||
|
|
70acad56fb | ||
|
|
629fddfca0 | ||
|
|
b68f68a1f2 | ||
|
|
754e9e8ad0 | ||
|
|
ed7521a563 | ||
|
|
bef30c2c86 | ||
|
|
8c00d1376d | ||
|
|
cea3f4d669 | ||
|
|
8409c51842 | ||
|
|
9560a9235f | ||
|
|
f17edfc0da | ||
|
|
c6bebec599 | ||
|
|
5a71e5bbfa | ||
|
|
5b0133d55e | ||
|
|
dcb733ac01 | ||
|
|
487cb1b12b | ||
|
|
58fdf1aca1 | ||
|
|
118f8b8971 | ||
|
|
8165b74e43 | ||
|
|
8ce4bc714f | ||
|
|
2d0bc0d75c | ||
|
|
d7e75797ef | ||
|
|
58bbdae26c | ||
|
|
3310a3c4a4 | ||
|
|
d8b0d86534 | ||
|
|
dc051fa9ae | ||
|
|
1fb1be47dd |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
</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" />
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; } = "";
|
|
||||||
}
|
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
100
backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs
generated
Normal file
100
backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
chatId = request.ChatId,
|
||||||
messageIds = request.MessageIds,
|
messageIds = request.MessageIds,
|
||||||
deleteForAll = true
|
deleteForAll = request.DeleteForAll
|
||||||
});
|
});
|
||||||
}
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -115,6 +115,7 @@ 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)
|
||||||
@@ -179,7 +183,8 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
(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)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -43,6 +44,9 @@ public static class DependencyInjection
|
|||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
44
client-mobile/.gitignore
vendored
Normal 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
20
client-mobile/.metadata
Normal 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
|
||||||
125
client-mobile/GETTING_STARTED.md
Normal file
125
client-mobile/GETTING_STARTED.md
Normal 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
51
client-mobile/README.md
Normal 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
|
||||||
8
client-mobile/analysis_options.yaml
Normal file
8
client-mobile/analysis_options.yaml
Normal 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
7
client-mobile/android/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
69
client-mobile/android/app/build.gradle
Normal file
69
client-mobile/android/app/build.gradle
Normal 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 {}
|
||||||
28
client-mobile/android/app/src/main/AndroidManifest.xml
Normal file
28
client-mobile/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.messenger_app
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity()
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
9
client-mobile/android/app/src/main/res/values/styles.xml
Normal file
9
client-mobile/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
25
client-mobile/android/build.gradle
Normal file
25
client-mobile/android/build.gradle
Normal 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
|
||||||
|
}
|
||||||
3
client-mobile/android/gradle.properties
Normal file
3
client-mobile/android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
5
client-mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
client-mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||||
25
client-mobile/android/settings.gradle
Normal file
25
client-mobile/android/settings.gradle
Normal 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"
|
||||||
14
client-mobile/ios/Flutter/Generated.xcconfig
Normal file
14
client-mobile/ios/Flutter/Generated.xcconfig
Normal 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
|
||||||
32
client-mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py
Normal file
32
client-mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py
Normal 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 --")
|
||||||
5
client-mobile/ios/Flutter/ephemeral/flutter_lldbinit
Normal file
5
client-mobile/ios/Flutter/ephemeral/flutter_lldbinit
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#
|
||||||
|
# Generated file, do not edit.
|
||||||
|
#
|
||||||
|
|
||||||
|
command script import --relative-to-command-file flutter_lldb_helper.py
|
||||||
13
client-mobile/ios/Flutter/flutter_export_environment.sh
Normal file
13
client-mobile/ios/Flutter/flutter_export_environment.sh
Normal 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"
|
||||||
13
client-mobile/ios/Runner/AppDelegate.swift
Normal file
13
client-mobile/ios/Runner/AppDelegate.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
client-mobile/ios/Runner/GeneratedPluginRegistrant.h
Normal file
19
client-mobile/ios/Runner/GeneratedPluginRegistrant.h
Normal 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 */
|
||||||
28
client-mobile/ios/Runner/GeneratedPluginRegistrant.m
Normal file
28
client-mobile/ios/Runner/GeneratedPluginRegistrant.m
Normal 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
6
client-mobile/l10n.yaml
Normal 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
|
||||||
93
client-mobile/l10n/app_en.arb
Normal file
93
client-mobile/l10n/app_en.arb
Normal 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"
|
||||||
|
}
|
||||||
93
client-mobile/l10n/app_ru.arb
Normal file
93
client-mobile/l10n/app_ru.arb
Normal 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": "Сообщение"
|
||||||
|
}
|
||||||
85
client-mobile/lib/app.dart
Normal file
85
client-mobile/lib/app.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
client-mobile/lib/core/constants/app_constants.dart
Normal file
25
client-mobile/lib/core/constants/app_constants.dart
Normal 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
|
||||||
|
}
|
||||||
9
client-mobile/lib/core/constants/storage_keys.dart
Normal file
9
client-mobile/lib/core/constants/storage_keys.dart
Normal 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';
|
||||||
|
}
|
||||||
22
client-mobile/lib/core/database/database_service.dart
Normal file
22
client-mobile/lib/core/database/database_service.dart
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
16
client-mobile/lib/core/errors/errors.dart
Normal file
16
client-mobile/lib/core/errors/errors.dart
Normal 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;
|
||||||
|
}
|
||||||
1814
client-mobile/lib/core/errors/errors.freezed.dart
Normal file
1814
client-mobile/lib/core/errors/errors.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
47
client-mobile/lib/core/errors/result.dart
Normal file
47
client-mobile/lib/core/errors/result.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
135
client-mobile/lib/core/network/api_client.dart
Normal file
135
client-mobile/lib/core/network/api_client.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
190
client-mobile/lib/core/network/signalr_service.dart
Normal file
190
client-mobile/lib/core/network/signalr_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
134
client-mobile/lib/core/theme/app_theme.dart
Normal file
134
client-mobile/lib/core/theme/app_theme.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user