Add user profile and avatar upload feature

This commit is contained in:
Aaron Po
2026-08-30 04:13:47 -04:00
parent bd78fe6533
commit d1d089aa73
15 changed files with 325 additions and 47 deletions

View File

@@ -123,20 +123,6 @@ app.MapHealthChecks("/health");
app.MapControllers();
app.MapPost(
"/upload-test",
async (IFormFile file, ClaimsPrincipal user, IMediator mediator) =>
{
Guid uploadedById = Guid.Parse(user.FindFirst(JwtRegisteredClaimNames.Sub)!.Value);
Guid photoId = await mediator.Send(
new UploadPhotoCommand(uploadedById, "upload-test", file)
);
return Results.Ok(photoId);
}
)
.RequireAuthorization()
.DisableAntiforgery();
app.MapFallbackToController("Handle404", "NotFound");
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();

View File

@@ -31,8 +31,24 @@ Table Photo {
Table UserAvatar {
UserAvatarID uniqueidentifier [pk, default: `newid()`]
UserAccountID uniqueidentifier [not null, unique]
UserProfileID uniqueidentifier [not null]
PhotoID uniqueidentifier [not null]
ValidFrom "datetime2(3)" [not null, default: `sysutcdatetime()`]
ValidTo "datetime2(3)" [note: 'NULL = currently active']
RowVersion rowversion
Note: 'Deleted when the user profile is deleted. At most one row per profile has ValidTo = NULL.'
Indexes {
UserProfileID [unique, name: 'UX_UserAvatar_ActiveOne', note: 'filtered: WHERE ValidTo IS NULL']
PhotoID [name: 'IX_UserAvatar_Photo']
}
}
Table UserProfile {
UserProfileID uniqueidentifier [pk, default: `newid()`]
Biography "nvarchar(max)" [not null]
UserAccountID uniqueidentifier [not null, unique]
RowVersion rowversion
Note: 'Deleted when the user account is deleted'
@@ -216,8 +232,9 @@ Table BeerPostComment {
}
Ref: Photo.UploadedByID > UserAccount.UserAccountID [delete: no action]
Ref: UserAvatar.UserAccountID - UserAccount.UserAccountID [delete: cascade]
Ref: UserAvatar.PhotoID > Photo.PhotoID
Ref: UserAvatar.PhotoID > Photo.PhotoID [delete: no action]
Ref: UserAvatar.UserProfileID > UserProfile.UserProfileID [delete: cascade]
Ref: UserProfile.UserAccountID - UserAccount.UserAccountID [delete: cascade]
Ref: UserVerification.UserAccountID - UserAccount.UserAccountID [delete: cascade]
Ref: UserCredential.UserAccountID > UserAccount.UserAccountID [delete: cascade]
Ref: UserFollow.UserAccountID > UserAccount.UserAccountID [delete: no action]

View File

@@ -29,3 +29,5 @@ CREATE TABLE dbo.UserAccount
CONSTRAINT AK_Email
UNIQUE (Email)
);

View File

@@ -1,29 +0,0 @@
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
(
UserAvatarID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
RowVersion ROWVERSION,
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
CONSTRAINT FK_UserAvatar_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE,
CONSTRAINT FK_UserAvatar_PhotoID
FOREIGN KEY (PhotoID)
REFERENCES Photo (PhotoID),
CONSTRAINT AK_UserAvatar_UserAccountID
UNIQUE (UserAccountID)
);
CREATE
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID);

View File

@@ -0,0 +1,22 @@
CREATE TABLE dbo.UserProfile
(
UserProfileID UNIQUEIDENTIFIER
CONSTRAINT DF_UserProfileID DEFAULT NEWID(),
Biography NVARCHAR(MAX) NOT NULL,
UserAccountID UNIQUEIDENTIFIER NOT NULL,
RowVersion ROWVERSION,
CONSTRAINT PK_UserProfile
PRIMARY KEY (UserProfileID),
CONSTRAINT AK_UserProfile_UserAccountID
UNIQUE (UserAccountID),
CONSTRAINT FK_UserProfile_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE
);

View File

@@ -0,0 +1,43 @@
CREATE TABLE dbo.UserAvatar
(
UserAvatarID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
UserProfileID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
ValidFrom DATETIME2(3) NOT NULL
CONSTRAINT DF_UserAvatar_ValidFrom DEFAULT SYSUTCDATETIME(),
ValidTo DATETIME2(3) NULL, -- NULL = currently active
RowVersion ROWVERSION,
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
CONSTRAINT FK_UserAvatar_UserProfile
FOREIGN KEY (UserProfileID)
REFERENCES dbo.UserProfile (UserProfileID)
ON DELETE CASCADE,
CONSTRAINT FK_UserAvatar_Photo
FOREIGN KEY (PhotoID)
REFERENCES dbo.Photo (PhotoID)
ON DELETE NO ACTION
ON UPDATE NO ACTION,
CONSTRAINT CK_UserAvatar_ValidRange
CHECK (ValidTo IS NULL OR ValidTo > ValidFrom)
);
-- At most one active avatar per profile
CREATE UNIQUE NONCLUSTERED INDEX UX_UserAvatar_ActiveOne
ON dbo.UserAvatar (UserProfileID)
WHERE ValidTo IS NULL;
CREATE NONCLUSTERED INDEX IX_UserAvatar_Photo
ON dbo.UserAvatar (PhotoID);
CREATE NONCLUSTERED INDEX ix_UserAvatar_Profile
ON dbo.UserAvatar (UserProfileID)

View File

@@ -0,0 +1,20 @@
namespace Domain.Entities;
public sealed class UserAvatar
{
// PK
public Guid UserAvatarId { get; set; }
// FK References
public Guid UserProfileId { get; set; }
public Guid PhotoId { get; set; }
// Attributes
public DateTime ValidFrom { get; set; }
public DateTime? ValidTo { get; set; }
// Navigation Field
public Photo? Photo { get; set; }
// Audit Fields
public byte[]? RowVersion { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Domain.Entities;
public sealed class UserProfile
{
// PK
public Guid UserProfileId { get; set; }
// FK References
public Guid UserAccountId { get; set; }
// Attributes
public string Biography { get; set; } = string.Empty;
// Audit Fields
public byte[]? RowVersion { get; set; }
}

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}