diff --git a/Sharecode.Backend.Api/Controller/UserController.cs b/Sharecode.Backend.Api/Controller/UserController.cs index 35f02c0..2ac2010 100644 --- a/Sharecode.Backend.Api/Controller/UserController.cs +++ b/Sharecode.Backend.Api/Controller/UserController.cs @@ -6,6 +6,7 @@ using Sharecode.Backend.Application.Features.Http.Users.Get; using Sharecode.Backend.Application.Features.Http.Users.GetMySnippets; using Sharecode.Backend.Application.Features.Http.Users.Metadata.List; +using Sharecode.Backend.Application.Features.Http.Users.Metadata.Upsert; using Sharecode.Backend.Application.Features.Http.Users.TagSearch; using Sharecode.Backend.Domain.Exceptions; using Sharecode.Backend.Utilities.Extensions; @@ -156,6 +157,23 @@ public async Task ListMetadata([FromRoute] Guid userId, [FromQuery return Ok(response); } + + [HttpPost("{userId}/metadata")] + public async Task UpsertMetadata([FromRoute] Guid userId, [FromBody] UpsertUserMetadataCommand command) + { + command.UserId = userId; + var res = await mediator.Send(command); + await ClearCacheAsync(); + return Ok(res); + } + + [HttpDelete("{userId}/metadata")] + public async Task DeleteMetadata([FromRoute] Guid userId) + { + + await ClearCacheAsync(); + return Ok(); + } #endregion diff --git a/Sharecode.Backend.Api/Validators/User/UserCommandValidator.cs b/Sharecode.Backend.Api/Validators/User/UserCommandValidator.cs index 23a366f..d2ba96d 100644 --- a/Sharecode.Backend.Api/Validators/User/UserCommandValidator.cs +++ b/Sharecode.Backend.Api/Validators/User/UserCommandValidator.cs @@ -3,6 +3,7 @@ using Sharecode.Backend.Application.Features.Http.Users.ForgotPassword; using Sharecode.Backend.Application.Features.Http.Users.Login; using Sharecode.Backend.Application.Features.Http.Users.Metadata.List; +using Sharecode.Backend.Application.Features.Http.Users.Metadata.Upsert; using Sharecode.Backend.Application.Features.Http.Users.TagSearch; using Sharecode.Backend.Domain.Enums; using Sharecode.Backend.Utilities.MetaKeys; @@ -74,6 +75,10 @@ public class ListUserMetadataQueryValidator : AbstractValidator x.Queries) + .Must(queries => queries.Any()) + .WithMessage("Queries should not be empty"); + RuleFor(x => x.Queries) .Must(queries => !queries.Any(key => RestrictedKeys.Contains(key))) .WithMessage(query => $"Queries contain restricted keys: {string.Join(", ", query.Queries.Where(key => RestrictedKeys.Contains(key)))}"); @@ -86,4 +91,18 @@ public ListUserMetadataQueryValidator() .NotNull() .WithMessage("Please ensure a proper user id in the query"); } +} + +public class UpsertUserMetadataCommandValidator : AbstractValidator +{ + public UpsertUserMetadataCommandValidator() + { + RuleFor(x => x.MetaDictionary) + .Must(queries => queries.Any()) + .WithMessage("External metadata(s) should not be empty"); + + RuleFor(x => x.MetaDictionary) + .Must(metaDic => metaDic.Keys.All(x => x.StartsWith("FE_"))) + .WithMessage(query => $"External metadata(s) should start with FE [For example: FE_userPassed], Invalid key(s): {string.Join(", ", query.MetaDictionary.Keys.Where(key => key.StartsWith("FE_")))}"); + } } \ No newline at end of file diff --git a/Sharecode.Backend.Application/Features/Http/Users/Delete/DeleteUserCommandHandler.cs b/Sharecode.Backend.Application/Features/Http/Users/Delete/DeleteUserCommandHandler.cs index 073532a..c5f054f 100644 --- a/Sharecode.Backend.Application/Features/Http/Users/Delete/DeleteUserCommandHandler.cs +++ b/Sharecode.Backend.Application/Features/Http/Users/Delete/DeleteUserCommandHandler.cs @@ -26,7 +26,7 @@ public async Task Handle(DeleteUserCommand request, Cancella Permissions.DeleteSnippetOthers); } logger.Information("An account deletion has been requested by {RequestUser} on Account Id {ToDelete}. Is the request to soft delete = {SoftDelete}", userIdentifier, request.UserId, request.SoftDelete); - var success = await service.DeleteUser(request.UserId, userIdentifier, request.SoftDelete ?? false, cancellationToken); + var success = await service.DeleteUserAsync(request.UserId, userIdentifier, request.SoftDelete ?? false, cancellationToken); logger.Information("The requested account deletion has been processed on Account Id {ToDelete}. Is the request to soft delete = {SoftDelete}. The response is {Response}", userIdentifier, request.UserId, request.SoftDelete, success); return new DeleteUserResponse { Success = success }; diff --git a/Sharecode.Backend.Application/Features/Http/Users/ForgotPassword/ForgotPasswordCommandHandler.cs b/Sharecode.Backend.Application/Features/Http/Users/ForgotPassword/ForgotPasswordCommandHandler.cs index 85c11c3..5476216 100644 --- a/Sharecode.Backend.Application/Features/Http/Users/ForgotPassword/ForgotPasswordCommandHandler.cs +++ b/Sharecode.Backend.Application/Features/Http/Users/ForgotPassword/ForgotPasswordCommandHandler.cs @@ -12,7 +12,7 @@ public async Task Handle(ForgotPasswordCommand request, CancellationToken { try { - var willSendPassword = await service.RequestForgotPassword(request.EmailAddress, token: cancellationToken); + var willSendPassword = await service.RequestForgotPasswordAsync(request.EmailAddress, token: cancellationToken); await unitOfWork.CommitAsync(cancellationToken); return willSendPassword; } diff --git a/Sharecode.Backend.Application/Features/Http/Users/GetMySnippets/GetMySnippetsQueryHandler.cs b/Sharecode.Backend.Application/Features/Http/Users/GetMySnippets/GetMySnippetsQueryHandler.cs index afc97a1..712109c 100644 --- a/Sharecode.Backend.Application/Features/Http/Users/GetMySnippets/GetMySnippetsQueryHandler.cs +++ b/Sharecode.Backend.Application/Features/Http/Users/GetMySnippets/GetMySnippetsQueryHandler.cs @@ -11,7 +11,7 @@ public class GetMySnippetsQueryHandler(IUserRepository userRepository, IUserServ public async Task Handle(GetMySnippetsQuery request, CancellationToken cancellationToken) { var userIdentifier = await context.GetUserIdentifierAsync(); - var listUserSnippets = await userService.ListUserSnippets(userIdentifier!.Value, request.OnlyOwned, request.RecentSnippets, request.Skip, request.Take, request.Order ?? "ASC", request.OrderBy ?? string.Empty, request.SearchQuery ?? string.Empty, cancellationToken); + var listUserSnippets = await userService.ListUserSnippetsAsync(userIdentifier!.Value, request.OnlyOwned, request.RecentSnippets, request.Skip, request.Take, request.Order ?? "ASC", request.OrderBy ?? string.Empty, request.SearchQuery ?? string.Empty, cancellationToken); GetMySnippetsResponse response = new GetMySnippetsResponse(listUserSnippets) { Query = request diff --git a/Sharecode.Backend.Application/Features/Http/Users/Metadata/Upsert/UpsertMetadataCommandHandler.cs b/Sharecode.Backend.Application/Features/Http/Users/Metadata/Upsert/UpsertMetadataCommandHandler.cs new file mode 100644 index 0000000..e126678 --- /dev/null +++ b/Sharecode.Backend.Application/Features/Http/Users/Metadata/Upsert/UpsertMetadataCommandHandler.cs @@ -0,0 +1,40 @@ +using MediatR; +using Serilog; +using Sharecode.Backend.Application.Client; +using Sharecode.Backend.Application.Data; +using Sharecode.Backend.Application.Exceptions; +using Sharecode.Backend.Application.Service; +using Sharecode.Backend.Domain.Entity.Profile; +using Sharecode.Backend.Domain.Exceptions; +using Sharecode.Backend.Utilities.RedisCache; + +namespace Sharecode.Backend.Application.Features.Http.Users.Metadata.Upsert; + +public class UpsertMetadataCommandHandler(ILogger logger, IHttpClientContext context, IUserService userService, IUnitOfWork unitOfWork) : IRequestHandler +{ + public async Task Handle(UpsertUserMetadataCommand request, CancellationToken cancellationToken) + { + var requestingUserIdRaw = await context.GetUserIdentifierAsync(); + if(!requestingUserIdRaw.HasValue) + throw new NoAccessException($"{request.UserId.ToString()}/metadata", Guid.Empty, typeof(User)); + + var requestingUserId = requestingUserIdRaw.Value; + if (requestingUserId != request.UserId) + { + if (!await context.HasPermissionAsync(Permissions.UpdateUserOtherAdmin, cancellationToken)) + { + throw new NotEnoughPermissionException("Update user metadata"); + } + } + + if (!request.MetaDictionary.Any()) + { + return UpsertUserMetadataResponse.Empty; + } + + var oldValues = await userService.UpdateExternalMetadataAsync(request.UserId, request.MetaDictionary, cancellationToken); + context.AddCacheKeyToInvalidate(CacheModules.UserMetadata, request.UserId.ToString()); + await unitOfWork.CommitAsync(cancellationToken); + return UpsertUserMetadataResponse.From(oldValues); + } +} \ No newline at end of file diff --git a/Sharecode.Backend.Application/Features/Http/Users/Metadata/Upsert/UpsertUserMetadataCommand.cs b/Sharecode.Backend.Application/Features/Http/Users/Metadata/Upsert/UpsertUserMetadataCommand.cs new file mode 100644 index 0000000..b529c64 --- /dev/null +++ b/Sharecode.Backend.Application/Features/Http/Users/Metadata/Upsert/UpsertUserMetadataCommand.cs @@ -0,0 +1,9 @@ +using Sharecode.Backend.Application.Base; + +namespace Sharecode.Backend.Application.Features.Http.Users.Metadata.Upsert; + +public class UpsertUserMetadataCommand : IAppRequest +{ + public Guid UserId { get; set; } + public Dictionary MetaDictionary = new(); +} \ No newline at end of file diff --git a/Sharecode.Backend.Application/Features/Http/Users/Metadata/Upsert/UpsertUserMetadataResponse.cs b/Sharecode.Backend.Application/Features/Http/Users/Metadata/Upsert/UpsertUserMetadataResponse.cs new file mode 100644 index 0000000..2bfa7a1 --- /dev/null +++ b/Sharecode.Backend.Application/Features/Http/Users/Metadata/Upsert/UpsertUserMetadataResponse.cs @@ -0,0 +1,22 @@ +namespace Sharecode.Backend.Application.Features.Http.Users.Metadata.Upsert; + +public class UpsertUserMetadataResponse +{ + public Dictionary OldValues = new(); + + private UpsertUserMetadataResponse(Dictionary oldValues) + { + OldValues = oldValues; + } + + private UpsertUserMetadataResponse() + { + } + + public static UpsertUserMetadataResponse From(Dictionary oldValues) + { + return new UpsertUserMetadataResponse(oldValues); + } + + public static UpsertUserMetadataResponse Empty => new(); +} \ No newline at end of file diff --git a/Sharecode.Backend.Application/Service/IUserService.cs b/Sharecode.Backend.Application/Service/IUserService.cs index e880f41..553b4d7 100644 --- a/Sharecode.Backend.Application/Service/IUserService.cs +++ b/Sharecode.Backend.Application/Service/IUserService.cs @@ -6,16 +6,90 @@ namespace Sharecode.Backend.Application.Service; public interface IUserService { - Task IsEmailAddressUnique(string emailAddress, CancellationToken token = default); + /// + /// Checks whether the given email address is unique. + /// + /// The email address to check. + /// Optional cancellation token. + /// A task that represents the asynchronous operation. + /// The task result contains true if the email address is unique; otherwise, false. + Task IsEmailAddressUniqueAsync(string emailAddress, CancellationToken token = default); + + /// + /// Verifies if a user with the specified ID exists asynchronously. + /// + /// The unique identifier of the user to verify. + /// A cancellation token that can be used to cancel the verification operation. + /// + /// A task representing the asynchronous operation. The task result is true if the user exists, + /// and false otherwise. + /// Task VerifyUserAsync(Guid userId, CancellationToken token = default); - Task RequestForgotPassword(string emailAddress, CancellationToken token = default); + + /// + /// Sends a request to reset the password for the specified email address. + /// + /// The email address for which the password needs to be reset. + /// A cancellation token that can be used to cancel the request. + /// A task representing the asynchronous operation. The task result will be true if the request was successfully sent; otherwise, false. + Task RequestForgotPasswordAsync(string emailAddress, CancellationToken token = default); + + /// + /// Resets the password for a user asynchronously. + /// + /// The unique identifier of the user. + /// The new password for the user. + /// The cancellation token. + /// A task representing the asynchronous operation. The result is true if the password is reset successfully; otherwise, false. Task ResetPasswordAsync(Guid userId, string password, CancellationToken token = default); + /// + /// Retrieves a list of users that can be tagged based on the given search query and other optional parameters. + /// + /// The search query used to filter the users. + /// The maximum number of users to retrieve. + /// The number of users to skip. + /// Flag indicating whether to include deleted users in the results. Default is false. + /// Flag indicating whether tagging should be enabled for the retrieved users. Default is true. + /// Cancellation token to cancel the operation if needed. + /// A task that represents the asynchronous operation. The task result contains a read-only list of users that match the search query and specified parameters. Task> GetUsersToTagAsync(string searchQuery, int take, int skip, bool includeDeleted = false, bool shouldEnableTagging = true, CancellationToken token = default); - Task> ListUserSnippets(Guid userId, bool onlyOwned = false, bool recentSnippets = true, - int skip = 0, int take = 20, string order = "ASC", string orderBy = "ModifiedAt", string searchQuery = null, + /// + /// Retrieves a list of snippets for a specified user. + /// + /// The ID of the user. + /// Optional. Specifies whether to only retrieve snippets owned by the user. Default is false. + /// Optional. Specifies whether to retrieve only recent snippets. Default is true. + /// Optional. The number of snippets to skip. Default is 0. + /// Optional. The number of snippets to retrieve. Default is 20. + /// Optional. The order in which to retrieve the snippets. Default is "ASC". + /// Optional. The property to order the snippets by. Default is "ModifiedAt". + /// Optional. The search query to filter the snippets by. Default is null. + /// Optional. The cancellation token. + /// A list of MySnippetsDto objects representing the snippets. + Task> ListUserSnippetsAsync(Guid userId, bool onlyOwned = false, bool recentSnippets = true, + int skip = 0, int take = 20, string order = "ASC", string orderBy = "ModifiedAt", string searchQuery = null, CancellationToken cancellationToken = default); - Task DeleteUser(Guid userId, Guid requestedBy, bool softDelete = true, CancellationToken token = default); + + /// + /// Deletes a user based on the provided user ID. + /// + /// The ID of the user to be deleted. + /// The ID of the user who requested the deletion. + /// Specifies whether the user should be soft deleted (default is true). + /// A cancellation token that can be used to cancel the asynchronous operation (default is CancellationToken.None). + /// A representing the asynchronous operation. The task result contains true if the user was successfully deleted; otherwise, false. + Task DeleteUserAsync(Guid userId, Guid requestedBy, bool softDelete = true, CancellationToken token = default); + + /// + /// Updates the external metadata for a user asynchronously. + /// + /// The ID of the user. + /// A dictionary containing the metadata values to be updated. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous operation. The task result contains a boolean value indicating whether the update was successful or not. + Task> UpdateExternalMetadataAsync(Guid userId, Dictionary metadataValues, + CancellationToken token = default); } \ No newline at end of file diff --git a/Sharecode.Backend.Application/Sharecode.Backend.Application.csproj b/Sharecode.Backend.Application/Sharecode.Backend.Application.csproj index c31c92f..99b00ba 100644 --- a/Sharecode.Backend.Application/Sharecode.Backend.Application.csproj +++ b/Sharecode.Backend.Application/Sharecode.Backend.Application.csproj @@ -20,5 +20,9 @@ + + + + diff --git a/Sharecode.Backend.Domain/Base/Primitive/BaseEntityWithMetadata.cs b/Sharecode.Backend.Domain/Base/Primitive/BaseEntityWithMetadata.cs index d2b423b..f73e770 100644 --- a/Sharecode.Backend.Domain/Base/Primitive/BaseEntityWithMetadata.cs +++ b/Sharecode.Backend.Domain/Base/Primitive/BaseEntityWithMetadata.cs @@ -20,6 +20,41 @@ public bool SetMeta(MetaKey key, object value) return true; } + /// + /// Sets the metadata value for the given key. + /// + /// The key of the metadata. + /// The value to set. Pass null to remove the metadata. + /// The previous value associated with the key if it exists, otherwise null. + public object? SetMeta(string key, object? value) + { + // Check that key is not null, empty, or whitespace + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Key cannot be null, empty, or whitespace.", nameof(key)); + + // Early return if the value is null + if (value == null) + { + if (Metadata.Remove(key, out var oldValue)) + { + return oldValue; + } + + return null; + } + + // Assign new value and return old value (if existed) + if (Metadata.TryGetValue(key, out var oldValue2)) + { + Metadata[key] = value; + return oldValue2; + } + + // No previous value, just set new value + Metadata[key] = value; + return null; + } + public T? ReadMeta(MetaKey key, T? defaultValue = default) { diff --git a/Sharecode.Backend.Domain/Entity/Profile/Permission.cs b/Sharecode.Backend.Domain/Entity/Profile/Permission.cs index dbd50f3..c2b5608 100644 --- a/Sharecode.Backend.Domain/Entity/Profile/Permission.cs +++ b/Sharecode.Backend.Domain/Entity/Profile/Permission.cs @@ -122,6 +122,7 @@ public static class Permissions #region Admin public static Permission ViewUserOtherAdmin => new("view-user-profile-others-admin", "View entire user information of others", true); + public static Permission UpdateUserOtherAdmin => new("update-user-profile-others-admin", "Update entire user information of others", true); #endregion diff --git a/Sharecode.Backend.Infrastructure/Service/UserService.cs b/Sharecode.Backend.Infrastructure/Service/UserService.cs index 881c4b9..db5538c 100644 --- a/Sharecode.Backend.Infrastructure/Service/UserService.cs +++ b/Sharecode.Backend.Infrastructure/Service/UserService.cs @@ -37,7 +37,7 @@ public UserService(ShareCodeDbContext dbContext, IUserRepository userRepository, _context = context; } - public async Task IsEmailAddressUnique(string emailAddress, CancellationToken token = default) + public async Task IsEmailAddressUniqueAsync(string emailAddress, CancellationToken token = default) { return await (_dbContext.Set() .AsNoTracking() @@ -59,7 +59,7 @@ public async Task VerifyUserAsync(Guid userId, CancellationToken token = d return user.VerifyUser(); } - public async Task RequestForgotPassword(string emailAddress, CancellationToken token = default) + public async Task RequestForgotPasswordAsync(string emailAddress, CancellationToken token = default) { var user = await _userRepository.GetAsync(emailAddress, true, token, true); if (user == null) @@ -105,7 +105,7 @@ public async Task> GetUsersToTagAsync(string searchQuery, in return mentionableUsers.ToList(); } - public async Task> ListUserSnippets(Guid userId, bool onlyOwned = false, + public async Task> ListUserSnippetsAsync(Guid userId, bool onlyOwned = false, bool recentSnippets = true, int skip = 0, int take = 20, string order = "ASC", string orderBy = "ModifiedAt", string searchQuery = null, CancellationToken cancellationToken = default) { @@ -158,7 +158,7 @@ public async Task> ListUserSnippets(Guid userId, bool onlyOw return (response); } - public async Task DeleteUser(Guid userId, Guid requestedBy, bool softDelete = true, CancellationToken token = default) + public async Task DeleteUserAsync(Guid userId, Guid requestedBy, bool softDelete = true, CancellationToken token = default) { var userToDelete = await _userRepository.GetAsync(userId, true, token); if (userToDelete == null) @@ -170,6 +170,24 @@ public async Task DeleteUser(Guid userId, Guid requestedBy, bool softDelet userToDelete.RequestAccountDeletion(softDelete, requestedBy); return true; } + + public async Task> UpdateExternalMetadataAsync(Guid userId, Dictionary metadataValues, CancellationToken token = default) + { + var user = await _userRepository.GetAsync(userId, token: token); + if (user == null) + throw new EntityNotFoundException(typeof(User), userId); + + Dictionary oldValues = new(); + + foreach (var (key, value) in metadataValues) + { + var val = user.SetMeta(key, value); + oldValues[key] = val; + } + + _userRepository.Update(user); + return oldValues; + } } internal static class UserSqlQueries