mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add user profile and avatar upload feature
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Features.Auth.Commands.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>;
|
||||
@@ -0,0 +1,45 @@
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Repository;
|
||||
using Features.ImageUploads.Commands.UploadPhoto;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.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,
|
||||
IUserAvatarRepository userAvatarRepository,
|
||||
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.GetOrCreateProfileIdAsync(
|
||||
request.UserAccountId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
UserAvatar avatar = new()
|
||||
{
|
||||
UserAvatarId = Guid.NewGuid(),
|
||||
UserProfileId = profileId,
|
||||
PhotoId = photoId,
|
||||
};
|
||||
|
||||
await userAvatarRepository.SaveAsync(avatar, cancellationToken);
|
||||
|
||||
return photoId;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ namespace Features.Auth.DependencyInjection;
|
||||
public static class FeaturesAuthServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds <see cref="ITokenService" />, <see cref="IUserListRepository" />, and ASP.NET Core
|
||||
/// Adds <see cref="ITokenService" />, <see cref="IUserListRepository" />,
|
||||
/// <see cref="IUserAvatarRepository" />, <see cref="IUserProfileRepository" />, and ASP.NET Core
|
||||
/// Identity (<see cref="UserManager{TUser}" /> backed by <see cref="DapperUserStore" /> and
|
||||
/// <see cref="Argon2PasswordHasher" />) as scoped services.
|
||||
/// </summary>
|
||||
@@ -20,6 +21,8 @@ public static class FeaturesAuthServiceCollectionExtensions
|
||||
services.AddScoped<ITokenService, TokenService>();
|
||||
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
services.AddScoped<IUserListRepository, UserListRepository>();
|
||||
services.AddScoped<IUserAvatarRepository, UserAvatarRepository>();
|
||||
services.AddScoped<IUserProfileRepository, UserProfileRepository>();
|
||||
|
||||
// AddDefaultTokenProviders() is deliberately not called: it registers
|
||||
// DataProtectorTokenProvider<TUser>, which UserManager's constructor resolves eagerly and which
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for persisting user avatar records.
|
||||
/// </summary>
|
||||
public interface IUserAvatarRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists <paramref name="avatar" />, replacing any existing avatar for the same user profile.
|
||||
/// </summary>
|
||||
Task SaveAsync(UserAvatar avatar, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for resolving user profile records.
|
||||
/// </summary>
|
||||
public interface IUserProfileRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the <c>UserProfileID</c> for <paramref name="userAccountId" />, creating an empty
|
||||
/// profile first if one doesn't already exist.
|
||||
/// </summary>
|
||||
Task<Guid> GetOrCreateProfileIdAsync(Guid userAccountId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Database.Connection;
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of <see cref="IUserAvatarRepository" />.
|
||||
/// </summary>
|
||||
public class UserAvatarRepository(ISqlConnectionFactory connectionFactory)
|
||||
: DapperRepository(connectionFactory),
|
||||
IUserAvatarRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task SaveAsync(UserAvatar avatar, CancellationToken cancellationToken)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
UPDATE dbo.UserAvatar
|
||||
SET ValidTo = SYSUTCDATETIME()
|
||||
WHERE UserProfileID = @UserProfileId AND ValidTo IS NULL
|
||||
""",
|
||||
new { avatar.UserProfileId },
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserAvatar (UserAvatarID, UserProfileID, PhotoID)
|
||||
VALUES (@UserAvatarId, @UserProfileId, @PhotoId)
|
||||
""",
|
||||
new { avatar.UserAvatarId, avatar.UserProfileId, avatar.PhotoId },
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await RollbackQuietlyAsync(transaction);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Database.Connection;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of <see cref="IUserProfileRepository" />.
|
||||
/// </summary>
|
||||
public class UserProfileRepository(ISqlConnectionFactory connectionFactory)
|
||||
: DapperRepository(connectionFactory),
|
||||
IUserProfileRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Guid> GetOrCreateProfileIdAsync(
|
||||
Guid userAccountId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// UPDLOCK/HOLDLOCK: AK_UserProfile_UserAccountID is composite with UserProfileID, so it
|
||||
// doesn't by itself stop two concurrent callers from creating duplicate profiles for the
|
||||
// same account; holding the lock through the transaction serializes them instead.
|
||||
Guid? userProfileId = await connection.ExecuteScalarAsync<Guid?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT UserProfileID
|
||||
FROM dbo.UserProfile WITH (UPDLOCK, HOLDLOCK)
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId },
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
if (userProfileId is null)
|
||||
{
|
||||
userProfileId = Guid.NewGuid();
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserProfile (UserProfileID, Biography, UserAccountID)
|
||||
VALUES (@UserProfileId, '', @UserAccountId)
|
||||
""",
|
||||
new { UserProfileId = userProfileId, UserAccountId = userAccountId },
|
||||
transaction
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return userProfileId.Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await RollbackQuietlyAsync(transaction);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user