Skip to content

Commit

Permalink
Add user metadata functionality
Browse files Browse the repository at this point in the history
Two HTTP endpoints have been added, allowing user metadata to be created and deleted. In addition, metadata validation has been implemented in UserCommandValidator. Async methods for user services have also been renamed to follow the standard convention, and a method for updating external metadata is added in the UserService class.
  • Loading branch information
AlenGeoAlex committed Jan 1, 2024
1 parent 076a8eb commit f49e57a
Show file tree
Hide file tree
Showing 13 changed files with 252 additions and 12 deletions.
18 changes: 18 additions & 0 deletions Sharecode.Backend.Api/Controller/UserController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -156,6 +157,23 @@ public async Task<ActionResult> ListMetadata([FromRoute] Guid userId, [FromQuery

return Ok(response);
}

[HttpPost("{userId}/metadata")]
public async Task<ActionResult> 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<ActionResult> DeleteMetadata([FromRoute] Guid userId)
{

await ClearCacheAsync();
return Ok();
}

#endregion

Expand Down
19 changes: 19 additions & 0 deletions Sharecode.Backend.Api/Validators/User/UserCommandValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -74,6 +75,10 @@ public class ListUserMetadataQueryValidator : AbstractValidator<ListUserMetadata

public ListUserMetadataQueryValidator()
{
RuleFor(x => 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)))}");
Expand All @@ -86,4 +91,18 @@ public ListUserMetadataQueryValidator()
.NotNull()
.WithMessage("Please ensure a proper user id in the query");
}
}

public class UpsertUserMetadataCommandValidator : AbstractValidator<UpsertUserMetadataCommand>
{
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_")))}");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public async Task<DeleteUserResponse> 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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public async Task<bool> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class GetMySnippetsQueryHandler(IUserRepository userRepository, IUserServ
public async Task<GetMySnippetsResponse> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UpsertUserMetadataCommand, UpsertUserMetadataResponse>
{
public async Task<UpsertUserMetadataResponse> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Sharecode.Backend.Application.Base;

namespace Sharecode.Backend.Application.Features.Http.Users.Metadata.Upsert;

public class UpsertUserMetadataCommand : IAppRequest<UpsertUserMetadataResponse>
{
public Guid UserId { get; set; }
public Dictionary<string, object> MetaDictionary = new();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Sharecode.Backend.Application.Features.Http.Users.Metadata.Upsert;

public class UpsertUserMetadataResponse
{
public Dictionary<string, object?> OldValues = new();

private UpsertUserMetadataResponse(Dictionary<string, object?> oldValues)
{
OldValues = oldValues;
}

private UpsertUserMetadataResponse()
{
}

public static UpsertUserMetadataResponse From(Dictionary<string, object?> oldValues)
{
return new UpsertUserMetadataResponse(oldValues);
}

public static UpsertUserMetadataResponse Empty => new();
}
84 changes: 79 additions & 5 deletions Sharecode.Backend.Application/Service/IUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,90 @@ namespace Sharecode.Backend.Application.Service;

public interface IUserService
{
Task<bool> IsEmailAddressUnique(string emailAddress, CancellationToken token = default);
/// <summary>
/// Checks whether the given email address is unique.
/// </summary>
/// <param name="emailAddress">The email address to check.</param>
/// <param name="token">Optional cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.
/// The task result contains true if the email address is unique; otherwise, false.</returns>
Task<bool> IsEmailAddressUniqueAsync(string emailAddress, CancellationToken token = default);

/// <summary>
/// Verifies if a user with the specified ID exists asynchronously.
/// </summary>
/// <param name="userId">The unique identifier of the user to verify.</param>
/// <param name="token">A cancellation token that can be used to cancel the verification operation.</param>
/// <returns>
/// A task representing the asynchronous operation. The task result is true if the user exists,
/// and false otherwise.
/// </returns>
Task<bool> VerifyUserAsync(Guid userId, CancellationToken token = default);
Task<bool> RequestForgotPassword(string emailAddress, CancellationToken token = default);

/// <summary>
/// Sends a request to reset the password for the specified email address.
/// </summary>
/// <param name="emailAddress">The email address for which the password needs to be reset.</param>
/// <param name="token">A cancellation token that can be used to cancel the request.</param>
/// <returns>A task representing the asynchronous operation. The task result will be true if the request was successfully sent; otherwise, false.</returns>
Task<bool> RequestForgotPasswordAsync(string emailAddress, CancellationToken token = default);

/// <summary>
/// Resets the password for a user asynchronously.
/// </summary>
/// <param name="userId">The unique identifier of the user.</param>
/// <param name="password">The new password for the user.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation. The result is <c>true</c> if the password is reset successfully; otherwise, <c>false</c>.</returns>
Task<bool> ResetPasswordAsync(Guid userId, string password, CancellationToken token = default);

/// <summary>
/// Retrieves a list of users that can be tagged based on the given search query and other optional parameters.
/// </summary>
/// <param name="searchQuery">The search query used to filter the users.</param>
/// <param name="take">The maximum number of users to retrieve.</param>
/// <param name="skip">The number of users to skip.</param>
/// <param name="includeDeleted">Flag indicating whether to include deleted users in the results. Default is false.</param>
/// <param name="shouldEnableTagging">Flag indicating whether tagging should be enabled for the retrieved users. Default is true.</param>
/// <param name="token">Cancellation token to cancel the operation if needed.</param>
/// <returns>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.</returns>
Task<IReadOnlyList<User>> GetUsersToTagAsync(string searchQuery, int take, int skip, bool includeDeleted = false,
bool shouldEnableTagging = true, CancellationToken token = default);

Task<List<MySnippetsDto>> ListUserSnippets(Guid userId, bool onlyOwned = false, bool recentSnippets = true,
int skip = 0, int take = 20, string order = "ASC", string orderBy = "ModifiedAt", string searchQuery = null,
/// <summary>
/// Retrieves a list of snippets for a specified user.
/// </summary>
/// <param name="userId">The ID of the user.</param>
/// <param name="onlyOwned">Optional. Specifies whether to only retrieve snippets owned by the user. Default is false.</param>
/// <param name="recentSnippets">Optional. Specifies whether to retrieve only recent snippets. Default is true.</param>
/// <param name="skip">Optional. The number of snippets to skip. Default is 0.</param>
/// <param name="take">Optional. The number of snippets to retrieve. Default is 20.</param>
/// <param name="order">Optional. The order in which to retrieve the snippets. Default is "ASC".</param>
/// <param name="orderBy">Optional. The property to order the snippets by. Default is "ModifiedAt".</param>
/// <param name="searchQuery">Optional. The search query to filter the snippets by. Default is null.</param>
/// <param name="cancellationToken">Optional. The cancellation token.</param>
/// <returns>A list of MySnippetsDto objects representing the snippets.</returns>
Task<List<MySnippetsDto>> 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<bool> DeleteUser(Guid userId, Guid requestedBy, bool softDelete = true, CancellationToken token = default);

/// <summary>
/// Deletes a user based on the provided user ID.
/// </summary>
/// <param name="userId">The ID of the user to be deleted.</param>
/// <param name="requestedBy">The ID of the user who requested the deletion.</param>
/// <param name="softDelete">Specifies whether the user should be soft deleted (default is true).</param>
/// <param name="token">A cancellation token that can be used to cancel the asynchronous operation (default is CancellationToken.None).</param>
/// <returns>A <see cref="Task{bool}"/> representing the asynchronous operation. The task result contains true if the user was successfully deleted; otherwise, false.</returns>
Task<bool> DeleteUserAsync(Guid userId, Guid requestedBy, bool softDelete = true, CancellationToken token = default);

/// <summary>
/// Updates the external metadata for a user asynchronously.
/// </summary>
/// <param name="userId">The ID of the user.</param>
/// <param name="metadataValues">A dictionary containing the metadata values to be updated.</param>
/// <param name="token">A cancellation token that can be used to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains a boolean value indicating whether the update was successful or not.</returns>
Task<Dictionary<string, object?>> UpdateExternalMetadataAsync(Guid userId, Dictionary<string, object> metadataValues,
CancellationToken token = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,9 @@
<PackageReference Include="Serilog" Version="3.1.2-dev-02097" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
</ItemGroup>

<ItemGroup>
<Folder Include="Features\Http\Users\Metadata\Delete\" />
</ItemGroup>

</Project>
35 changes: 35 additions & 0 deletions Sharecode.Backend.Domain/Base/Primitive/BaseEntityWithMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,41 @@ public bool SetMeta(MetaKey key, object value)
return true;
}

/// <summary>
/// Sets the metadata value for the given key.
/// </summary>
/// <param name="key">The key of the metadata.</param>
/// <param name="value">The value to set. Pass null to remove the metadata.</param>
/// <returns>The previous value associated with the key if it exists, otherwise null.</returns>
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<T>(MetaKey key, T? defaultValue = default)
{
Expand Down
1 change: 1 addition & 0 deletions Sharecode.Backend.Domain/Entity/Profile/Permission.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 22 additions & 4 deletions Sharecode.Backend.Infrastructure/Service/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public UserService(ShareCodeDbContext dbContext, IUserRepository userRepository,
_context = context;
}

public async Task<bool> IsEmailAddressUnique(string emailAddress, CancellationToken token = default)
public async Task<bool> IsEmailAddressUniqueAsync(string emailAddress, CancellationToken token = default)
{
return await (_dbContext.Set<User>()
.AsNoTracking()
Expand All @@ -59,7 +59,7 @@ public async Task<bool> VerifyUserAsync(Guid userId, CancellationToken token = d
return user.VerifyUser();
}

public async Task<bool> RequestForgotPassword(string emailAddress, CancellationToken token = default)
public async Task<bool> RequestForgotPasswordAsync(string emailAddress, CancellationToken token = default)
{
var user = await _userRepository.GetAsync(emailAddress, true, token, true);
if (user == null)
Expand Down Expand Up @@ -105,7 +105,7 @@ public async Task<IReadOnlyList<User>> GetUsersToTagAsync(string searchQuery, in
return mentionableUsers.ToList();
}

public async Task<List<MySnippetsDto>> ListUserSnippets(Guid userId, bool onlyOwned = false,
public async Task<List<MySnippetsDto>> 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)
{
Expand Down Expand Up @@ -158,7 +158,7 @@ public async Task<List<MySnippetsDto>> ListUserSnippets(Guid userId, bool onlyOw
return (response);
}

public async Task<bool> DeleteUser(Guid userId, Guid requestedBy, bool softDelete = true, CancellationToken token = default)
public async Task<bool> DeleteUserAsync(Guid userId, Guid requestedBy, bool softDelete = true, CancellationToken token = default)
{
var userToDelete = await _userRepository.GetAsync(userId, true, token);
if (userToDelete == null)
Expand All @@ -170,6 +170,24 @@ public async Task<bool> DeleteUser(Guid userId, Guid requestedBy, bool softDelet
userToDelete.RequestAccountDeletion(softDelete, requestedBy);
return true;
}

public async Task<Dictionary<string, object?>> UpdateExternalMetadataAsync(Guid userId, Dictionary<string, object> metadataValues, CancellationToken token = default)
{
var user = await _userRepository.GetAsync(userId, token: token);
if (user == null)
throw new EntityNotFoundException(typeof(User), userId);

Dictionary<string, object?> 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
Expand Down

0 comments on commit f49e57a

Please sign in to comment.