внедрил пресеты статусов и модульную архитектуру
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
using FluentAssertions;
|
||||
using Knot.Modules.Profiles.Application.Profiles.UpdateProfile;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Modules.Profiles.Application.Abstractions;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Profiles.UnitTests;
|
||||
|
||||
@@ -25,14 +25,11 @@ public class UpdateProfileCommandHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_ShouldReturnError_WhenProfileNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null);
|
||||
_profileRepository.GetByIdAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((ProfileDocument?)null);
|
||||
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null, null);
|
||||
_profileRepository.GetAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((UserProfileDto?)null);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsFailure.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ public static class ProfilesErrors
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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,6 +12,10 @@ public class UserProfileDto
|
||||
public DateTime? LastSeen { get; set; }
|
||||
public DateTime? Birthday { 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; }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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);
|
||||
}
|
||||
@@ -250,6 +250,7 @@ app.MapStoriesEndpoints();
|
||||
app.MapContactsEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapProfilesEndpoints();
|
||||
app.MapStatusesEndpoints();
|
||||
app.MapFederationEndpoints();
|
||||
app.MapKlipyEndpoints();
|
||||
app.MapChatsEndpoints();
|
||||
|
||||
@@ -125,7 +125,6 @@ public sealed class ChatHub : Hub
|
||||
_userConnections.TryRemove(userId, out _);
|
||||
try
|
||||
{
|
||||
// Connection is tearing down: ConnectionAborted is already signaled and will cancel I/O.
|
||||
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, CancellationToken.None);
|
||||
if (!isInvisible)
|
||||
{
|
||||
|
||||
@@ -6,8 +6,5 @@ public record UpdateProfileRequest(
|
||||
string? DisplayName,
|
||||
string? Bio,
|
||||
DateTime? Birthday,
|
||||
string? StatusText,
|
||||
string? StatusEmoji,
|
||||
DateTime? StatusExpiresAt,
|
||||
bool? IsInvisible);
|
||||
public record UpdateSettingsRequest(bool? HideStoryViews);
|
||||
|
||||
@@ -12,9 +12,6 @@ public sealed record UpdateProfileCommand(
|
||||
string? DisplayName,
|
||||
string? Bio,
|
||||
DateTime? Birthday,
|
||||
string? StatusText,
|
||||
string? StatusEmoji,
|
||||
DateTime? StatusExpiresAt,
|
||||
bool? IsInvisible) : ICommand<UserProfileDto>;
|
||||
|
||||
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
|
||||
@@ -35,15 +32,9 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
|
||||
if ((request.Bio?.Length ?? 0) > 200)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.BioTooLong);
|
||||
|
||||
if ((request.StatusText?.Length ?? 0) > 50)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusTextTooLong);
|
||||
|
||||
profile.DisplayName = request.DisplayName ?? profile.DisplayName;
|
||||
profile.About = request.Bio;
|
||||
profile.Birthday = request.Birthday;
|
||||
profile.StatusText = request.StatusText;
|
||||
profile.StatusEmoji = request.StatusEmoji;
|
||||
profile.StatusExpiresAt = request.StatusExpiresAt;
|
||||
profile.IsInvisible = request.IsInvisible ?? profile.IsInvisible;
|
||||
|
||||
var result = await _repository.UpdateAsync(profile, cancellationToken);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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,10 +11,11 @@ public static class DependencyInjection
|
||||
public static IServiceCollection AddProfilesModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
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.IAvatarStorageService, AvatarStorageService>();
|
||||
|
||||
// MongoDB Registration
|
||||
var mongoConnection = configuration.GetConnectionString("MongoConnection")
|
||||
?? configuration["MONGO_URL"]
|
||||
?? "mongodb://mongo:27017";
|
||||
|
||||
@@ -3,10 +3,6 @@ using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Knot.Modules.Profiles.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB-документ профиля пользователя.
|
||||
/// Id совпадает с UserId из модуля Auth (Postgres).
|
||||
/// </summary>
|
||||
public sealed class ProfileDocument
|
||||
{
|
||||
[BsonId]
|
||||
@@ -25,6 +21,9 @@ public sealed class ProfileDocument
|
||||
|
||||
public DateTime? StatusExpiresAt { get; private set; }
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid? CurrentStatusId { get; private set; }
|
||||
|
||||
public string? AvatarUrl { get; private set; }
|
||||
|
||||
public DateTime? Birthday { get; private set; }
|
||||
@@ -83,6 +82,16 @@ public sealed class ProfileDocument
|
||||
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)
|
||||
{
|
||||
IsBanned = isBanned;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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,29 +10,39 @@ namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||
internal class ProfileRepository : IProfileRepository
|
||||
{
|
||||
private readonly IMongoCollection<ProfileDocument> _profiles;
|
||||
private readonly IUserStatusRepository _statuses;
|
||||
|
||||
public ProfileRepository(IMongoDatabase database)
|
||||
public ProfileRepository(IMongoDatabase database, IUserStatusRepository statuses)
|
||||
{
|
||||
_profiles = database.GetCollection<ProfileDocument>("profiles");
|
||||
_statuses = statuses;
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto?> GetAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct);
|
||||
return profile?.ToDto();
|
||||
return profile is null ? null : await profile.ToDtoAsync(_statuses, ct);
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto?> GetByUsernameAsync(string username, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct);
|
||||
return profile?.ToDto();
|
||||
return profile is null ? null : await profile.ToDtoAsync(_statuses, ct);
|
||||
}
|
||||
|
||||
public async Task<List<UserProfileDto>> GetAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = userIds.ToList();
|
||||
var profiles = await _profiles.Find(p => ids.Contains(p.Id)).ToListAsync(ct);
|
||||
return profiles.Select(p => p.ToDto()).ToList();
|
||||
var statusIds = profiles
|
||||
.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)
|
||||
@@ -57,7 +67,16 @@ internal class ProfileRepository : IProfileRepository
|
||||
var combinedFilter = Builders<ProfileDocument>.Filter.And(baseFilter, searchFilter);
|
||||
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)
|
||||
@@ -65,7 +84,7 @@ internal class ProfileRepository : IProfileRepository
|
||||
var profile = ProfileDocument.Create(dto.UserId, dto.Username ?? string.Empty, dto.DisplayName ?? string.Empty, dto.About);
|
||||
|
||||
await _profiles.InsertOneAsync(profile, null, ct);
|
||||
return Result.Success(profile.ToDto());
|
||||
return Result.Success(await profile.ToDtoAsync(_statuses, ct));
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken ct = default)
|
||||
@@ -80,11 +99,13 @@ internal class ProfileRepository : IProfileRepository
|
||||
dto.Birthday);
|
||||
|
||||
document.UpdateAvatar(dto.Avatar);
|
||||
document.UpdateStatus(dto.StatusText, dto.StatusEmoji, dto.StatusExpiresAt);
|
||||
document.UpdateInvisible(dto.IsInvisible);
|
||||
|
||||
await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct);
|
||||
return Result.Success(document.ToDto());
|
||||
var fresh = await _profiles.Find(p => p.Id == dto.UserId).FirstOrDefaultAsync(ct);
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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,16 +1,14 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Profiles.Infrastructure.Mappings;
|
||||
|
||||
public static class ProfileMappings
|
||||
{
|
||||
public static UserProfileDto ToDto(this ProfileDocument document)
|
||||
public static UserProfileDto ToDtoWithStatusMap(ProfileDocument document, IReadOnlyDictionary<Guid, UserStatusDto> statusMap)
|
||||
{
|
||||
var hasActiveStatus = !document.StatusExpiresAt.HasValue || document.StatusExpiresAt.Value > DateTime.UtcNow;
|
||||
|
||||
return new UserProfileDto
|
||||
var dto = new UserProfileDto
|
||||
{
|
||||
UserId = document.Id,
|
||||
DisplayName = document.DisplayName,
|
||||
@@ -21,11 +19,59 @@ public static class ProfileMappings
|
||||
LastSeen = null,
|
||||
Birthday = document.Birthday,
|
||||
IsPremium = false,
|
||||
StatusText = hasActiveStatus ? document.StatusText : null,
|
||||
StatusEmoji = hasActiveStatus ? document.StatusEmoji : null,
|
||||
StatusExpiresAt = hasActiveStatus ? document.StatusExpiresAt : null,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +81,6 @@ public static class ProfilesEndpoints
|
||||
request.DisplayName,
|
||||
request.Bio,
|
||||
request.Birthday,
|
||||
request.StatusText,
|
||||
request.StatusEmoji,
|
||||
request.StatusExpiresAt,
|
||||
request.IsInvisible),
|
||||
ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user