Add user avatar and profile feature, consolidate Auth and UserManagement features (#257)

This commit is contained in:
Aaron Po
2026-08-31 00:11:48 -04:00
committed by GitHub
parent ade2128a73
commit 39c5a89ca7
130 changed files with 1005 additions and 613 deletions

View File

@@ -0,0 +1,6 @@
using MediatR;
namespace Features.Auth.Commands.Profile.CreateUserProfile;
/// <summary>Creates a new user profile for a user account.</summary>
public record CreateUserProfileCommand(Guid UserAccountId, string Biography) : IRequest<Guid>;

View File

@@ -0,0 +1,19 @@
using Features.Auth.Repository;
using MediatR;
namespace Features.Auth.Commands.Profile.CreateUserProfile;
/// <summary>Handles <see cref="CreateUserProfileCommand" /> by inserting the new profile record.</summary>
public class CreateUserProfileHandler(IUserProfileRepository userProfileRepository)
: IRequestHandler<CreateUserProfileCommand, Guid>
{
public Task<Guid> Handle(
CreateUserProfileCommand request,
CancellationToken cancellationToken
) =>
userProfileRepository.CreateProfileAsync(
request.UserAccountId,
request.Biography,
cancellationToken
);
}

View File

@@ -0,0 +1,10 @@
using MediatR;
namespace Features.Auth.Commands.Profile.UpdateBiography;
/// <summary>Updates the biography of the given user account's profile.</summary>
/// <param name="UserAccountId">
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
/// client-supplied input.
/// </param>
public record UpdateBiographyCommand(Guid UserAccountId, string Biography) : IRequest;

View File

@@ -0,0 +1,19 @@
using Features.Auth.Repository;
using MediatR;
namespace Features.Auth.Commands.Profile.UpdateBiography;
/// <summary>Handles <see cref="UpdateBiographyCommand" /> by updating the stored biography.</summary>
public class UpdateBiographyHandler(IUserProfileRepository userProfileRepository)
: IRequestHandler<UpdateBiographyCommand>
{
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no user profile exists for the given user account.
/// </exception>
public Task Handle(UpdateBiographyCommand request, CancellationToken cancellationToken) =>
userProfileRepository.UpdateBiographyAsync(
request.UserAccountId,
request.Biography,
cancellationToken
);
}

View File

@@ -0,0 +1,19 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Commands.Profile.UpdateProfile;
/// <summary>
/// Updates the non-credential profile fields (first name, last name, date of birth) of the given
/// user account.
/// </summary>
/// <param name="UserAccountId">
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
/// client-supplied input.
/// </param>
public record UpdateProfileCommand(
Guid UserAccountId,
string FirstName,
string LastName,
DateTime DateOfBirth
) : IRequest<UpdateProfilePayload>;

View File

@@ -0,0 +1,40 @@
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Identity;
using MediatR;
using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.Profile.UpdateProfile;
/// <summary>
/// Handles <see cref="UpdateProfileCommand" /> via <see cref="UserManager{TUser}" />. First/last name
/// and date of birth aren't tracked by any Identity-specific store interface, so the mutated
/// <see cref="ApplicationUser" /> is saved directly through <see cref="UserManager{TUser}.UpdateAsync" />.
/// </summary>
public class UpdateProfileHandler(UserManager<ApplicationUser> userManager)
: IRequestHandler<UpdateProfileCommand, UpdateProfilePayload>
{
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
/// <exception cref="ConflictException">Thrown when the update otherwise fails.</exception>
public async Task<UpdateProfilePayload> Handle(
UpdateProfileCommand request,
CancellationToken cancellationToken
)
{
ApplicationUser user =
await userManager.FindByIdAsync(request.UserAccountId.ToString())
?? throw new NotFoundException("User account not found");
user.FirstName = request.FirstName;
user.LastName = request.LastName;
user.DateOfBirth = request.DateOfBirth;
IdentityResult result = await userManager.UpdateAsync(user);
if (!result.Succeeded)
throw new ConflictException(
string.Join("; ", result.Errors.Select(e => e.Description))
);
return new UpdateProfilePayload(user.Id, user.FirstName, user.LastName, user.DateOfBirth);
}
}

View File

@@ -0,0 +1,28 @@
using FluentValidation;
namespace Features.Auth.Commands.Profile.UpdateProfile;
/// <summary>Validates <see cref="UpdateProfileCommand" />, mirroring the profile rules in <c>RegisterUserValidator</c>.</summary>
public class UpdateProfileValidator : AbstractValidator<UpdateProfileCommand>
{
public UpdateProfileValidator()
{
RuleFor(x => x.FirstName)
.NotEmpty()
.WithMessage("First name is required")
.MaximumLength(128)
.WithMessage("First name cannot exceed 128 characters");
RuleFor(x => x.LastName)
.NotEmpty()
.WithMessage("Last name is required")
.MaximumLength(128)
.WithMessage("Last name cannot exceed 128 characters");
RuleFor(x => x.DateOfBirth)
.NotEmpty()
.WithMessage("Date of birth is required")
.LessThan(DateTime.Today.AddYears(-19))
.WithMessage("You must be at least 19 years old");
}
}

View File

@@ -0,0 +1,7 @@
using MediatR;
using Microsoft.AspNetCore.Http;
namespace Features.Auth.Commands.Profile.UploadAvatar;
/// <summary>Uploads a new avatar photo for a user account, replacing any existing avatar.</summary>
public record UploadAvatarCommand(Guid UserAccountId, IFormFile File) : IRequest<Guid>;

View File

@@ -0,0 +1,42 @@
using Domain.Entities;
using Features.Auth.Repository;
using Features.ImageUploads.Commands.UploadPhoto;
using MediatR;
namespace Features.Auth.Commands.Profile.UploadAvatar;
/// <summary>
/// Handles <see cref="UploadAvatarCommand" />: uploads the photo via <see cref="UploadPhotoCommand" />,
/// then saves the <see cref="UserAvatar" /> record, replacing any existing avatar for the user.
/// </summary>
public class UploadAvatarHandler(IMediator mediator, IUserProfileRepository userProfileRepository)
: IRequestHandler<UploadAvatarCommand, Guid>
{
public async Task<Guid> Handle(UploadAvatarCommand request, CancellationToken cancellationToken)
{
Guid photoId = await mediator.Send(
new UploadPhotoCommand(
request.UserAccountId,
$"avatars/{request.UserAccountId}",
request.File
),
cancellationToken
);
Guid profileId = await userProfileRepository.GetProfileIdAsync(
request.UserAccountId,
cancellationToken
);
UserAvatar avatar = new()
{
UserAvatarId = Guid.NewGuid(),
UserProfileId = profileId,
PhotoId = photoId,
};
await userProfileRepository.SaveAvatarAsync(avatar, cancellationToken);
return photoId;
}
}